diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index b73368bd..98753628 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -10,7 +10,7 @@ # All intermediate environments updated with same artifact # Fails entirely if any step fails (no partial state) # -# Release states (based on position): +# Release states (role, or position when no role is set): # # Breaking changes: # Breaking changes block at: pre-release → release AND release → prod diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index d9657bb8..f281cf54 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -82,8 +82,8 @@ }, "environments": { "type": "array", - "items": { "type": "string" }, - "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + "items": { "$ref": "#/definitions/environmentEntry" }, + "description": "Ordered promotion ladder. Each entry is a bare name (sugar) or an object with the environment's name, optional role, and inline settings. Empty means a no-environment setup (library or CLI projects)." }, "cli_version": { "type": "string", @@ -181,11 +181,6 @@ "description": "Explicit action reference pins keyed by action name." }, "telemetry": { "$ref": "#/definitions/telemetryConfig" }, - "environment_config": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, - "description": "Per-environment settings keyed by environment name." - }, "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, @@ -527,7 +522,7 @@ "properties": { "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, - "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "environments": { "type": "array", "items": { "$ref": "#/definitions/environmentEntry" }, "description": "Overrides the shared promotion ladder for this component (bare names or objects with inline settings); whole-replaces the inherited list." }, "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, "validate": { "$ref": "#/definitions/validateConfig" }, @@ -547,7 +542,6 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "rollback": { "$ref": "#/definitions/rollbackConfig" }, "deployments": { "$ref": "#/definitions/deploymentsConfig" }, - "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, "extra_paths": { "type": "array", "items": { "type": "string" }, "description": "Repo-relative globs beyond this component's own path that both fire its orchestrate workflow and count toward its version bump, so a change to a shared dependency this component consumes bumps it correctly. Additive to path and to any top-level shared_paths." }, "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, @@ -822,11 +816,28 @@ "secret_name": { "type": "string", "description": "Name of a GitHub Actions secret holding the auth token (a reference, never an inline token). Reserved." } } }, + "environmentEntry": { + "oneOf": [ + { "type": "string", "description": "Environment name; sugar for an environment with no inline settings." }, + { "$ref": "#/definitions/environmentConfig" } + ], + "description": "One entry in the environments ladder: a bare name or an object carrying the name, an optional role, and inline per-environment settings." + }, "environmentConfig": { "type": "object", "additionalProperties": false, - "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.", + "required": ["name"], + "description": "An environments entry in object form: the environment name, an optional promotion role, and the inline per-environment settings the cascade environments command emits as an operator-appliable GitHub Environments REST config.", "properties": { + "name": { + "type": "string", + "description": "Environment name. Keys job IDs and expression references and defines this entry's rung on the promotion ladder." + }, + "role": { + "type": "string", + "enum": ["prerelease", "release"], + "description": "Optional explicit promotion stage, overriding the positional default (last entry = release, second-from-last = prerelease)." + }, "gha_environment": { "type": "string", "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)." diff --git a/docs/src/content/docs/guides/components.md b/docs/src/content/docs/guides/components.md index 3ede4592..8612a783 100644 --- a/docs/src/content/docs/guides/components.md +++ b/docs/src/content/docs/guides/components.md @@ -41,9 +41,10 @@ and overridden per component only where it differs. When a `components:` block is present, the top-level config becomes the set of **shared defaults** every component inherits. Each entry under `components:` overrides those defaults where it sets a value and inherits them everywhere else. -Overrides are a deep merge: a component that sets only part of a block, such as a -single `environment_config` entry or one `tag_grammar` field, keeps the inherited -siblings rather than dropping them. See the [override +Overrides are a deep merge: a component that sets only part of a block, such as +one `tag_grammar` field, keeps the inherited siblings rather than dropping them. +The `environments` list is the exception, whole-replacing the shared ladder +(inline per-environment settings and all) when a component narrows it. See the [override matrix](/cascade/reference/manifest/#inheritable-overrides) for the precise per-field rules. diff --git a/docs/src/content/docs/guides/environments.md b/docs/src/content/docs/guides/environments.md index 9ae54733..72926909 100644 --- a/docs/src/content/docs/guides/environments.md +++ b/docs/src/content/docs/guides/environments.md @@ -7,7 +7,7 @@ This guide covers adding an environment to an existing pipeline, configuring eac ## Add an environment -`environments` is an ordered list; position, not name, carries meaning. The last environment is the release stage, the second-to-last is the prerelease stage. Add a name at the position you want, then regenerate: +`environments` is an ordered list; position defines the promotion ladder. By default the last environment is the release stage and the second-to-last is the prerelease stage. Each entry is a bare name (an environment with no extra settings) or an object carrying that environment's name, optional `role`, and inline settings. Add an entry at the position you want, then regenerate: ```yaml environments: [dev, staging, prod] @@ -17,15 +17,16 @@ environments: [dev, staging, prod] cascade generate-workflow -f ``` -cascade adds the new environment's `state.` entry automatically the next time orchestrate or promote finalizes; you never hand-author `state:`. Appending to the end of the list shifts which environment is the release stage, since that role is always the last position, so reorder deliberately rather than just appending if you want to keep an existing environment as production. +cascade adds the new environment's `state.` entry automatically the next time orchestrate or promote finalizes; you never hand-author `state:`. Appending to the end of the list shifts which environment is the release stage, since that role defaults to the last position, so reorder deliberately rather than just appending if you want to keep an existing environment as production. To pin the release stage regardless of order, set `role: release` on that entry (see [Roles](#pin-a-role-explicitly)). ## Per-environment config -`environment_config.` carries settings for one environment, keyed by its cascade name. All fields are optional and additive. +Per-environment settings live inline on an environment's object entry, replacing the bare string. All fields are optional and additive; a bare-string entry carries none of them. ```yaml -environment_config: - prod: +environments: + - dev + - name: prod gha_environment: production required_reviewers: ["octocat", "team/ops"] wait_timer: 10 @@ -53,6 +54,21 @@ A few more fields round out the block, mostly for the `custom` branch policy or GitHub Environment support is shipped: `gha_environment` drives native GitHub deployments and `environment_url`, and the fields above feed the `environments` command below. It lands in generated output today, not a "modeled but not emitted" state. +## Pin a role explicitly + +By default the release and prerelease stages are positional: the last environment is the release stage, the second-to-last is prerelease. Set `role: release` or `role: prerelease` on an entry to declare that stage explicitly, so reordering the list no longer silently changes which environment publishes. + +```yaml +environments: + - name: dev + - name: prod + role: release # prod publishes even if it is not last + - name: canary + role: prerelease +``` + +`role` is optional; when omitted the positional default applies unchanged. At most one entry may declare `role: release` and at most one `role: prerelease`. + ## Apply GitHub Environment settings with the `environments` command `cascade environments` reads your manifest and emits a per-environment configuration file for an operator to apply. cascade never calls the GitHub API itself: diff --git a/docs/src/content/docs/internals/architecture.md b/docs/src/content/docs/internals/architecture.md index 56d92a9c..1a40d9f6 100644 --- a/docs/src/content/docs/internals/architecture.md +++ b/docs/src/content/docs/internals/architecture.md @@ -180,9 +180,9 @@ See [Security](/cascade/security/) for the full trust model, action-pinning poli - **Custom release**: override with `release.tag` to hand releases to an external tool. - **Custom inputs**: pass arbitrary values into a callback via `inputs` and `env_inputs`. - **Output chaining**: a callback's outputs are auto-discovered and passed to whatever depends on it. -- **GitHub Environments**: `environment_config` lets a manifest express required reviewers, wait timers, and branch policy per environment; `cascade environments` emits that as a file for an operator to apply. Cascade never calls the Environments REST API itself, so applying the config stays a deliberate operator step. See [the manifest reference](/cascade/reference/manifest/) for the field shape. +- **GitHub Environments**: the inline settings on an `environments` entry let a manifest express required reviewers, wait timers, and branch policy per environment; `cascade environments` emits that as a file for an operator to apply. Cascade never calls the Environments REST API itself, so applying the config stays a deliberate operator step. See [the manifest reference](/cascade/reference/manifest/) for the field shape. -New fields under `environment_config` and similar blocks are additive by design: a manifest that omits them is valid and behaves exactly as it does today, so this extension point can grow without a schema version bump. +New inline fields on the `environments` entries and similar blocks are additive by design: a manifest that omits them is valid and behaves exactly as it does today, so this extension point can grow without a schema version bump. ## Wayfinding diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index a4786bf2..e0beefd3 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -1068,14 +1068,15 @@ cascade environments | jq -c '.environments[] | {gha_environment, environment}' done ``` -The per-environment settings come from the manifest under -`config.environment_config.`: +The per-environment settings come from the inline fields on each +`config.environments` entry: ```yaml config: - environments: [dev, test, prod] - environment_config: - prod: + environments: + - dev + - test + - name: prod gha_environment: production required_reviewers: [team/ops] wait_timer: 10 diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md index 522f879b..b14a1eeb 100644 --- a/docs/src/content/docs/reference/manifest.md +++ b/docs/src/content/docs/reference/manifest.md @@ -85,19 +85,34 @@ The two fields that define the pipeline shape. | Field | Status | Type | Required | Default | Description | |-------|--------|------|----------|---------|-------------| | `trunk_branch` | emitted | string | Yes | `main` | The trunk branch releases flow from. | -| `environments` | emitted | list | No | - | The promotion chain. Omit for a no-environment library or CLI project. | +| `environments` | emitted | list of strings or objects | No | - | The promotion ladder. Each entry is a bare name or an object carrying that environment's name, optional role, and inline settings. Omit for a no-environment library or CLI project. | ```yaml ci: config: schema_version: 1 trunk_branch: main - environments: [dev, test, prod] + environments: + - dev # bare string: an environment with no extra settings + - name: staging + wait_timer: 5 + - name: prod + gha_environment: production + environment_url: "https://app.example.com" cli_version: v0.9.1 ``` -:::note[Environment names are yours; roles are positional] -The `environments` list is fully configurable. cascade attaches no meaning to specific labels: `dev`, `test`, `staging`, and `prod` are illustrative, not reserved. Roles are decided by position, not by name. The last environment is the release stage, the second-to-last is the prerelease environment, and the publish boundary is the final crossing into the last environment. The count is structural too: zero environments is release-only, one environment generates a single-environment Release workflow, and two or more enable the full promote cascade. +Each entry is one of two forms: + +- a **bare string** (`- dev`), sugar for an environment with no inline settings; +- an **object** (`- {name: dev, ...}`) carrying `name`, an optional `role`, and the + inline per-environment settings ([below](#per-environment-settings)). + +The list order defines the promotion ladder, so a plain string list behaves exactly as +before. + +:::note[Environment names are yours; roles default to position] +The `environments` list is fully configurable. cascade attaches no meaning to specific labels: `dev`, `test`, `staging`, and `prod` are illustrative, not reserved. By default roles are decided by position, not by name: the last environment is the release stage, the second-to-last is the prerelease environment, and the publish boundary is the final crossing into the last environment. Set `role: release` or `role: prerelease` on an entry to declare that stage explicitly and override the positional default (see [`role`](#per-environment-settings)). The count is structural too: zero environments is release-only, one environment generates a single-environment Release workflow, and two or more enable the full promote cascade. **Naming.** Environment, build, and deploy names become GitHub Actions job IDs and output keys, so keep them identifier-safe: letters, digits, and underscores (hyphens read as subtraction in GitHub Actions expressions). The generator-owned names `environment` and `dry_run` cannot be used as `dispatch_inputs`. ::: @@ -598,16 +613,18 @@ ci: Leave it unset (the default) to keep the gate on. A manifest that omits the field generates byte-identical workflows to before. -## environment_config +## Per-environment settings -Per-environment settings keyed by environment name. Consumed for native GitHub Environment support and deployment URLs. +Per-environment settings live inline on each [`environments`](#environments) entry in its +object form, keyed by that entry rather than in a separate map. They are consumed for +native GitHub Environment support and deployment URLs. ```yaml ci: config: - environments: [production] - environment_config: - production: + environments: + - name: production + role: release gha_environment: production environment_url: "https://app.example.com" required_reviewers: [octocat] @@ -617,11 +634,14 @@ ci: | Sub-field | Status | Description | |-----------|--------|-------------| +| `name` | emitted | The environment name (required on an object entry). Defines this entry's rung on the ladder. | +| `role` | emitted | Optional explicit promotion stage (`prerelease` or `release`), overriding the positional default. | | `gha_environment` | emitted | Maps the cascade environment to a real GitHub Environment (native deployments, `environment_url`). | | `environment_url` | emitted | URL reported on the Deployment status for that environment. | | `required_reviewers` | emitted (via `environments` command) | Reviewers the `environments` command applies to the GitHub Environment. | | `wait_timer` | emitted (via `environments` command) | Wait timer the `environments` command applies. | -| `branch_policy` | emitted (via `environments` command) | Branch policy the `environments` command applies. | +| `branch_policy` | emitted (via `environments` command) | Branch policy the `environments` command applies (with `branch_patterns` and `tag_patterns` when `custom`). | +| `secrets` / `variables` | emitted (via `environments` command) | Expected env-scoped secret and variable NAMES (names only, never values). | GitHub Environment support is shipped: `gha_environment` is consumed for native deployments, and the `cascade environments` command emits `required_reviewers`, `wait_timer`, and `branch_policy` for an operator to apply. See [Add or change environments](/cascade/guides/environments/). @@ -748,7 +768,7 @@ Reports deployment status through the GitHub Deployments API from the finalize j | `enabled` | emitted | bool | false | Create a Deployment and report status. Adds `deployments: write` to top-level permissions only when enabled. | | `keep_prior_active` | emitted | bool | false | Set `auto_inactive: false` so GitHub leaves prior deployments Active. | -Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea the steps are skipped. Pair with `environment_config..environment_url` so the status links to the running environment. +Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea the steps are skipped. Pair with the `environment_url` field on that environment's [`environments`](#environments) entry so the status links to the running environment. ### validate_check @@ -815,7 +835,7 @@ where an override is meaningful. An unset field takes the shared top-level value `validate`, `builds`, `deploys`, `publish`, `external`, `notify`, `release`, `changelog`, `runs_on`, `job_timeout_minutes`, `dispatch_inputs`, `extra_triggers`, `pr_preview`, `validate_check`, `rollback`, `deployments`, -`environment_config`, `triggers`, `release_token`, and `release_token_app`. +`triggers`, `release_token`, and `release_token_app`. Inheritance is a **deep merge**. When a component overrides a block, it merges field-by-field into the inherited default rather than replacing it wholesale, so @@ -825,13 +845,14 @@ a partial override never drops the shared siblings it did not mention: `tag_grammar.prefix` keeps the inherited `prerelease_token`, `prerelease_separator`, and `dryrun_token`. A component that sets only `deployments.keep_prior_active` keeps the inherited `deployments.enabled`. -- A **keyed map** such as `environment_config` merges by key, and the entry under - each key merges field-by-field. A component that configures only its `prod` - environment keeps the shared `dev` and `staging` entries, and a `prod` entry that - sets only `wait_timer` keeps the inherited `gha_environment` for `prod`. -- A **scalar or a list** replaces. A component `environments` list overrides the - shared list outright, and a scalar such as `release_trigger` overrides the shared - value. An explicit opt-out is honored: a component that sets an inherited boolean +- A **keyed map** such as `dispatch_inputs` merges by key, and the entry under + each key merges field-by-field. A component that configures only one input key + keeps the shared entries under the other keys. +- A **scalar or a list** replaces. The `environments` list whole-replaces the + shared ladder outright, inline per-environment settings and all: a component that + narrows to `environments: [prod]` declares its own settings on that entry rather + than inheriting the shared `prod` block. A scalar such as `release_trigger` + overrides the shared value. An explicit opt-out is honored: a component that sets an inherited boolean back to `false` (for example `deployments.enabled: false` under a shared `deployments.enabled: true`), or an inherited number to `0`, overrides the shared value rather than inheriting it. diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md index f416a8e2..81492819 100644 --- a/docs/src/content/docs/security.md +++ b/docs/src/content/docs/security.md @@ -102,7 +102,7 @@ Cascade does not configure the receiving side: the OIDC trust policy in AWS, GCP ## GitHub Environments as a gate -GitHub Environments are the shipped answer for gating a deploy with required reviewers, wait timers, and branch or tag restrictions. `environment_config..gha_environment` on the manifest wires a cascade-managed environment (native GitHub deployments, `environment_url`) onto that environment's deploy jobs, and the `environments` command emits the matching per-environment settings (`required_reviewers`, `wait_timer`, `branch_policy`) for an operator to apply through the GitHub API or UI. +GitHub Environments are the shipped answer for gating a deploy with required reviewers, wait timers, and branch or tag restrictions. The `gha_environment` field on an environment's `environments` entry wires a cascade-managed environment (native GitHub deployments, `environment_url`) onto that environment's deploy jobs, and the `environments` command emits the matching per-environment settings (`required_reviewers`, `wait_timer`, `branch_policy`) for an operator to apply through the GitHub API or UI. The one structural caveat: GitHub does not allow a job-level `environment:` on a job that calls a reusable workflow. A reusable deploy's gate has to live inside the called workflow, and cascade warns at generation time when a manifest wires a reusable deploy without one. See [Add or change environments](/cascade/guides/environments/) for the full setup walkthrough. @@ -114,7 +114,7 @@ Work through this when standing up or reviewing a cascade pipeline. The order mo 2. **Protect trunk and tags, and add CODEOWNERS.** Require review on `main` and on `.github/workflows/**`, and protect release and version tags from being moved or deleted. Run `branch-protection --apply` with a repo-admin token, or apply the emitted JSON manually. 3. **Turn on `pin_mode: sha`.** SHA pinning is shipped; it just is not the default. Set it explicitly if your threat model includes a retargeted third-party action tag. 4. **Scope every callback's `permissions:` and `secrets:`.** Do not lean on reusable-workflow secret inheritance. List exactly the secrets and permission scopes each callback needs, and add `id-token: write` only to callbacks that actually deploy or publish. -5. **Gate every production deploy with a GitHub Environment.** Set `environment_config..gha_environment`, run the `environments` command, and apply the emitted settings. For reusable deploys, place the `environment:` gate inside the called workflow. +5. **Gate every production deploy with a GitHub Environment.** Set `gha_environment` on that environment's `environments` entry, run the `environments` command, and apply the emitted settings. For reusable deploys, place the `environment:` gate inside the called workflow. 6. **Scope your cloud's OIDC trust policy narrowly.** Restrict it to the specific repository, environment, and ref that should be allowed to exchange a token, and issue short-lived sessions instead of long-lived static credentials. 7. **Restrict your repository's Actions settings.** Allow-list the specific actions and reusable workflows your pipeline needs, require approval for fork-PR runs, and set the default workflow token to read-only. 8. **Protect artifact integrity in the registry.** Use immutable tags and registry RBAC so a published artifact cannot be replaced, and deploy by the recorded digest where your deploy target supports it. diff --git a/e2e/harness/hotfix_actions_test.go b/e2e/harness/hotfix_actions_test.go index 6a609e1e..51ae4a83 100644 --- a/e2e/harness/hotfix_actions_test.go +++ b/e2e/harness/hotfix_actions_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" ) // TestParseHotfixScenario verifies the new step actions, divergence expectation @@ -389,7 +391,7 @@ func TestRunnerHotfixActionsNoHarness(t *testing.T) { t.Run(step.Action, func(t *testing.T) { r := NewRunner(t, nil) step := step - err := r.executeStep(ctx, &step, Config{Environments: []string{"dev", "prod"}}) + err := r.executeStep(ctx, &step, Config{Environments: config.EnvNames("dev", "prod")}) assert.NoError(t, err) }) } diff --git a/e2e/harness/multi_repo_scenario_test.go b/e2e/harness/multi_repo_scenario_test.go index e69638ee..ddfde72a 100644 --- a/e2e/harness/multi_repo_scenario_test.go +++ b/e2e/harness/multi_repo_scenario_test.go @@ -100,13 +100,13 @@ func TestMultiRepoRunner_Setup(t *testing.T) { "service-a": { Config: config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), }, }, "service-b": { Config: config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), }, }, }, @@ -144,7 +144,7 @@ func TestMultiRepoRunner_CommitStep(t *testing.T) { "my-repo": { Config: config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, }, }, @@ -198,7 +198,7 @@ func TestMultiRepoRunner_Interpolation(t *testing.T) { "my-repo": { Config: config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, }, }, @@ -250,7 +250,7 @@ func TestMultiRepoRunner_CrossRepoDispatch(t *testing.T) { Name: "primary", Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "org/satellite", @@ -275,7 +275,7 @@ func TestMultiRepoRunner_CrossRepoDispatch(t *testing.T) { Name: "satellite", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -330,13 +330,13 @@ func TestMultiRepoRunner_FullScenario(t *testing.T) { "primary": { Config: config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), }, }, "satellite": { Config: config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), }, }, }, @@ -403,7 +403,7 @@ func TestMultiRepoRunner_ConcurrentExternalUpdatesPreserveBothSlots(t *testing.T Name: "primary", Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "org/cdk-infra", @@ -435,7 +435,7 @@ func TestMultiRepoRunner_ConcurrentExternalUpdatesPreserveBothSlots(t *testing.T Name: "cdk-infra", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -450,7 +450,7 @@ func TestMultiRepoRunner_ConcurrentExternalUpdatesPreserveBothSlots(t *testing.T Name: "lambda-service", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "lambda", Workflow: ".github/workflows/deploy.yaml"}, }, diff --git a/e2e/harness/multi_repo_test.go b/e2e/harness/multi_repo_test.go index 2171f509..feacc15d 100644 --- a/e2e/harness/multi_repo_test.go +++ b/e2e/harness/multi_repo_test.go @@ -27,7 +27,7 @@ func TestMultiRepoHarness_CreateRepo(t *testing.T) { Name: "test-service", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -67,7 +67,7 @@ func TestMultiRepoHarness_SetupPrimarySatellite(t *testing.T) { Name: "primary-backend", Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "api", Workflow: ".github/workflows/deploy-api.yaml"}, }, @@ -87,7 +87,7 @@ func TestMultiRepoHarness_SetupPrimarySatellite(t *testing.T) { Name: "cdk-infra", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"cdk/**"}}, }, @@ -137,7 +137,7 @@ func TestMultiRepoHarness_CommitToRepo(t *testing.T) { Name: "commit-test", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, } @@ -173,7 +173,7 @@ func TestMultiRepoHarness_TagOperations(t *testing.T) { Name: "tag-test", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, } @@ -207,7 +207,7 @@ func TestMultiRepoHarness_FileContent(t *testing.T) { Name: "file-test", Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, Commits: []Commit{ { @@ -242,9 +242,9 @@ func TestMultiRepoHarness_MultipleRepos(t *testing.T) { // Create multiple independent repos repos := []MultiRepoSetup{ - {Name: "service-a", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev"}}}, - {Name: "service-b", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev"}}}, - {Name: "service-c", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev"}}}, + {Name: "service-a", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev")}}, + {Name: "service-b", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev")}}, + {Name: "service-c", Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev")}}, } for _, setup := range repos { diff --git a/e2e/harness/multistep_test.go b/e2e/harness/multistep_test.go index d8e67e72..4f1851d7 100644 --- a/e2e/harness/multistep_test.go +++ b/e2e/harness/multistep_test.go @@ -53,7 +53,7 @@ steps: require.NoError(t, err) assert.Equal(t, "Two Environment Happy Path", scenario.Name) - assert.Equal(t, []string{"dev", "prod"}, scenario.Config.Environments) + assert.Equal(t, []string{"dev", "prod"}, scenario.Config.EnvironmentNames()) assert.Len(t, scenario.Steps, 2) // First step is a commit diff --git a/e2e/harness/reconcile_companion_actions.go b/e2e/harness/reconcile_companion_actions.go index 685cee86..f522aa0f 100644 --- a/e2e/harness/reconcile_companion_actions.go +++ b/e2e/harness/reconcile_companion_actions.go @@ -101,7 +101,7 @@ func RunReconcileCompanionAppendScenario(ctx context.Context, t *testing.T) (*Re cfg := Config{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Reconcile: &config.ReconcileConfig{Enabled: true}, Builds: []config.BuildConfig{ {Name: "build", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 57eac505..bbd1210a 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -1254,7 +1254,7 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi // diverged env to exercise the diverged-source guard. source := "dev" if len(config.Environments) > 0 { - source = config.Environments[0] + source = config.Environments[0].Name } if promote.Source != "" { source = promote.Source @@ -1376,7 +1376,7 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { // once it's been promoted into any later env, it's "blessed" and the // workflow's prerelease step would have flipped its draft flag. Collect // the set of RC versions that are present in any non-firstEnv state. - promotedRCs := r.readPromotedRCVersions(ctx, config.Environments) + promotedRCs := r.readPromotedRCVersions(ctx, config.EnvironmentNames()) // Record tags and create release entries for version tags // In real GitHub, the workflow creates both tags and releases diff --git a/e2e/harness/runner_test.go b/e2e/harness/runner_test.go index 4e77faef..ce0ac103 100644 --- a/e2e/harness/runner_test.go +++ b/e2e/harness/runner_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" ) func TestRunner_ExecuteStep_Commit(t *testing.T) { @@ -35,7 +37,7 @@ func TestRunner_ValidateScenario(t *testing.T) { scenario := &MultiStepScenario{ Name: "Test", Config: Config{ - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), }, Steps: []Step{ {Name: "Step 1", Action: "commit", Commit: &CommitStep{Message: "test", Files: map[string]string{"a": "b"}}}, @@ -381,7 +383,7 @@ func TestRunner_Run_SimpleScenario(t *testing.T) { scenario := &MultiStepScenario{ Name: "Simple test", Config: Config{ - Environments: []string{}, + Environments: config.EnvNames(), }, Steps: []Step{ { diff --git a/e2e/init_scaffold_test.go b/e2e/init_scaffold_test.go index 6be70e24..ba0ed116 100644 --- a/e2e/init_scaffold_test.go +++ b/e2e/init_scaffold_test.go @@ -62,7 +62,7 @@ func TestInitScaffoldOrchestratesAndPromotes(t *testing.T) { require.NoError(t, err, "parsing scaffolded manifest into harness config") require.Equal(t, "main", cfg.TrunkBranch, "scaffolded trunk branch") - require.Equal(t, envs, cfg.Environments, "scaffolded environments") + require.Equal(t, envs, cfg.EnvironmentNames(), "scaffolded environments") require.Len(t, cfg.Builds, 1, "scaffolded builds") require.Len(t, cfg.Deploys, 1, "scaffolded deploys") require.Equal(t, "build", cfg.Builds[0].Name, "scaffolded build name") diff --git a/e2e/pin_reconcile_test.go b/e2e/pin_reconcile_test.go index b4bd0f93..0741e404 100644 --- a/e2e/pin_reconcile_test.go +++ b/e2e/pin_reconcile_test.go @@ -38,7 +38,7 @@ func TestReconcileAdoptsBumpAndSurvivesRegen(t *testing.T) { cfg := harness.Config{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), PinMode: config.PinModeSHA, Builds: []config.BuildConfig{ { diff --git a/e2e/scenarios/29-environment-config-emit.yaml b/e2e/scenarios/29-environment-config-emit.yaml index 63a9b89b..5a8b4170 100644 --- a/e2e/scenarios/29-environment-config-emit.yaml +++ b/e2e/scenarios/29-environment-config-emit.yaml @@ -1,18 +1,29 @@ name: "Environment Config Emit" description: | - Exercises the additive per-environment fields under environment_config + Exercises the per-environment fields inlined on the environments entries (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 + scenario declares the enriched environments list of objects, generates the workflows, then regenerates and proves the output is byte-identical with no - drift, confirming the new fields parse and validate without changing + drift, confirming the folded fields parse and validate without changing generation. secrets and variables are expected NAMES only, never values. config: trunk_branch: main - environments: [dev, prod] + environments: + - name: dev + branch_policy: custom + branch_patterns: [main, "release/*"] + tag_patterns: ["v*"] + - name: prod + gha_environment: production + required_reviewers: [octocat, team/ops] + wait_timer: 10 + branch_policy: protected + secrets: [MY_SECRET, DB_PASSWORD] + variables: [REGION] builds: - name: app workflow: build.yaml @@ -21,18 +32,6 @@ config: - 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" diff --git a/e2e/scenarios/31-native-deployments.yaml b/e2e/scenarios/31-native-deployments.yaml index 11dc8948..8c6f04c4 100644 --- a/e2e/scenarios/31-native-deployments.yaml +++ b/e2e/scenarios/31-native-deployments.yaml @@ -18,13 +18,12 @@ description: | config: trunk_branch: main - environments: [production] + environments: + - name: production + environment_url: "https://app.example.com" deployments: enabled: true keep_prior_active: true - environment_config: - production: - environment_url: "https://app.example.com" builds: - name: app workflow: build.yaml diff --git a/e2e/scenarios/60-environments-folded-object.yaml b/e2e/scenarios/60-environments-folded-object.yaml new file mode 100644 index 00000000..7dfa0845 --- /dev/null +++ b/e2e/scenarios/60-environments-folded-object.yaml @@ -0,0 +1,54 @@ +name: "Environments Folded Object" +description: | + Exercises the folded environments list end to end: a single environments list + mixes the bare-string sugar (dev) with object entries carrying inline + per-environment settings (a gha_environment mapping, a wait timer, and an + environment_url), replacing the former separate environment_config map. The + scenario declares native GitHub Deployments so the folded environment_url is + consumed by generation, generates the workflows, then regenerates and proves + the output is byte-identical with no drift, confirming the folded object shape + parses, validates, and reaches the native-environment and deployment sinks. + +config: + trunk_branch: main + environments: + - dev + - name: staging + gha_environment: staging + wait_timer: 5 + - name: prod + gha_environment: production + environment_url: "https://app.example.com" + deployments: + enabled: true + keep_prior_active: true + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Seed a minimal source tree and generate" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + - "environment_url=\"https://app.example.com\"" + + - name: "Regenerate and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/e2e/scenarios/61-environment-role-override.yaml b/e2e/scenarios/61-environment-role-override.yaml new file mode 100644 index 00000000..05f63942 --- /dev/null +++ b/e2e/scenarios/61-environment-role-override.yaml @@ -0,0 +1,49 @@ +name: "Environment Role Override" +description: | + Exercises the explicit environment role: an environments entry declares + role: release on an environment that is NOT the last in the list, overriding + the positional default (last entry = release, second-from-last = prerelease). + The scenario proves a manifest carrying an explicit role parses, validates, and + generates its full workflow set, and that regeneration is byte-identical with + no drift. The promotion graph honors the declared release marker rather than + the list position; the unit suite asserts the precise marker placement, while + this scenario proves the role is wired through real generation end to end. + +config: + trunk_branch: main + environments: + - name: dev + - name: staging + role: release + - name: prod + role: prerelease + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Seed a minimal source tree and generate" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + expect: + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - "promote:" + + - name: "Regenerate and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/internal/config/component_version_test.go b/internal/config/component_version_test.go index 213933fa..12a6481d 100644 --- a/internal/config/component_version_test.go +++ b/internal/config/component_version_test.go @@ -5,7 +5,7 @@ import "testing" func twoComponentTrunk() *TrunkConfig { return &TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), Components: map[string]ComponentConfig{ "api": {Path: "services/api", TagGrammar: &TagGrammarConfig{Prefix: strptr("api-")}}, "web": {Path: "services/web", TagGrammar: &TagGrammarConfig{Prefix: strptr("web-")}}, diff --git a/internal/config/components_deepcopy_test.go b/internal/config/components_deepcopy_test.go index 10e27286..09046385 100644 --- a/internal/config/components_deepcopy_test.go +++ b/internal/config/components_deepcopy_test.go @@ -9,7 +9,7 @@ import "testing" func baseComponentConfig() *TrunkConfig { return &TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), ActionPins: map[string]string{"actions/checkout": "v4"}, Git: &GitConfig{UserName: "shared-bot", UserEmail: "bot@example.com"}, Components: map[string]ComponentConfig{ @@ -37,12 +37,12 @@ func TestResolveComponent_NoSiblingBleed(t *testing.T) { } // Slice: overwrite api's inherited environments in place. - api.Config.Environments[0] = "MUTATED" - if web.Config.Environments[0] != "dev" { - t.Errorf("slice bleed: web env[0] = %q, want dev", web.Config.Environments[0]) + api.Config.Environments[0].Name = "MUTATED" + if web.Config.Environments[0].Name != "dev" { + t.Errorf("slice bleed: web env[0] = %q, want dev", web.Config.Environments[0].Name) } - if c.Environments[0] != "dev" { - t.Errorf("slice bleed into source: c env[0] = %q, want dev", c.Environments[0]) + if c.Environments[0].Name != "dev" { + t.Errorf("slice bleed into source: c env[0] = %q, want dev", c.Environments[0].Name) } // Map: mutate api's inherited action pins. diff --git a/internal/config/components_deepmerge_test.go b/internal/config/components_deepmerge_test.go index b7ed81a0..b0d18095 100644 --- a/internal/config/components_deepmerge_test.go +++ b/internal/config/components_deepmerge_test.go @@ -2,21 +2,23 @@ package config import "testing" -// TestResolveComponent_DeepMergesEnvironmentConfig proves a component that sets -// only one environment's config inherits the shared entries for the others, and -// that within an overridden entry the unset fields fall back to the shared -// entry's values. Under the previous whole-replace semantics the component's -// single-key map dropped the shared dev/staging entries entirely. -func TestResolveComponent_DeepMergesEnvironmentConfig(t *testing.T) { +// TestResolveComponent_EnvironmentsWholeReplaces proves the folded environments: +// list is a replace-leaf under component inheritance: a component that sets its +// own environments: list (settings and all) replaces the shared list outright +// rather than merging per-entry, while a component that leaves environments: +// unset inherits the shared list, per-entry settings and all. This mirrors the +// documented EnvironmentEntry contract (its custom UnmarshalYAML makes it a +// replace-leaf) now that the per-env settings live inline on each entry instead +// of in a separately-keyed environment_config map. +func TestResolveComponent_EnvironmentsWholeReplaces(t *testing.T) { cfg := parseInline(t, ` trunk_branch: main -environments: [dev, staging, prod] -environment_config: - dev: +environments: + - name: dev gha_environment: development - staging: + - name: staging gha_environment: staging-env - prod: + - name: prod gha_environment: production wait_timer: 10 components: @@ -24,8 +26,9 @@ components: path: services/api tag_grammar: prefix: api- - environment_config: - prod: + environments: + - name: prod + gha_environment: production wait_timer: 15 web: path: services/web @@ -33,27 +36,36 @@ components: prefix: web- `) - rc, err := cfg.ResolveComponent("api") + api, err := cfg.ResolveComponent("api") if err != nil { t.Fatalf("ResolveComponent(api): %v", err) } - ec := rc.Config.EnvironmentConfig - if len(ec) != 3 { - t.Fatalf("environment_config keys = %v, want dev/staging/prod all present", ec) + // api set its own environments: list, so the shared dev/staging entries do + // not carry over; only the component's own prod entry is present. + if got := api.Config.Environments; len(got) != 1 || got[0].Name != "prod" { + t.Fatalf("api environments = %v, want only its own override [prod]", got) + } + if got := api.Config.Environments[0].WaitTimerMinutes(); got != 15 { + t.Errorf("api prod wait_timer = %d, want override 15", got) + } + + web, err := cfg.ResolveComponent("web") + if err != nil { + t.Fatalf("ResolveComponent(web): %v", err) } - if ec["dev"].GHAEnvironment != "development" { - t.Errorf("dev env_config dropped: %#v", ec["dev"]) + // web left environments: unset, so it inherits the shared list intact, + // including each entry's inline settings. + if len(web.Config.Environments) != 3 { + t.Fatalf("web environments = %v, want inherited dev/staging/prod", web.Config.Environments) } - if ec["staging"].GHAEnvironment != "staging-env" { - t.Errorf("staging env_config dropped: %#v", ec["staging"]) + if web.Config.Environments[0].GHAEnvironment != "development" { + t.Errorf("web dev env dropped: %#v", web.Config.Environments[0]) } - // Within the overridden prod entry: wait_timer overrides, gha_environment - // inherits the shared entry (within-key deep merge). - if ec["prod"].WaitTimerMinutes() != 15 { - t.Errorf("prod wait_timer = %d, want override 15", ec["prod"].WaitTimerMinutes()) + if web.Config.Environments[1].GHAEnvironment != "staging-env" { + t.Errorf("web staging env dropped: %#v", web.Config.Environments[1]) } - if ec["prod"].GHAEnvironment != "production" { - t.Errorf("prod gha_environment = %q, want inherited production", ec["prod"].GHAEnvironment) + if web.Config.Environments[2].WaitTimerMinutes() != 10 { + t.Errorf("web prod wait_timer = %d, want inherited 10", web.Config.Environments[2].WaitTimerMinutes()) } } diff --git a/internal/config/components_resolve_test.go b/internal/config/components_resolve_test.go index 1e90d1be..d0b299e1 100644 --- a/internal/config/components_resolve_test.go +++ b/internal/config/components_resolve_test.go @@ -49,7 +49,7 @@ components: if eff.JobTimeoutMinutes != 30 { t.Errorf("job_timeout_minutes = %d, want inherited 30", eff.JobTimeoutMinutes) } - if len(eff.Environments) != 2 || eff.Environments[0] != "dev" { + if len(eff.Environments) != 2 || eff.Environments[0].Name != "dev" { t.Errorf("environments = %v, want inherited [dev prod]", eff.Environments) } if len(eff.Builds) != 1 || eff.Builds[0].Name != "app" { @@ -85,7 +85,7 @@ components: } eff := rc.Config - if got := eff.Environments; len(got) != 1 || got[0] != "dev" { + if got := eff.Environments; len(got) != 1 || got[0].Name != "dev" { t.Errorf("environments override = %v, want [dev]", got) } if eff.ReleaseTrigger != "dispatch" { diff --git a/internal/config/environments_fold_test.go b/internal/config/environments_fold_test.go new file mode 100644 index 00000000..9dfa9816 --- /dev/null +++ b/internal/config/environments_fold_test.go @@ -0,0 +1,285 @@ +package config + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestEnvironmentsFold_ObjectFormReachesConfig proves an environments entry in +// the object form carries its inline per-environment settings, folding what used +// to live in the separate environment_config map. The settings must reach the +// same accessors the generator and the environments command consume. +func TestEnvironmentsFold_ObjectFormReachesConfig(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: + - dev + - name: prod + gha_environment: production + wait_timer: 10 + branch_policy: protected + required_reviewers: [octocat, team/ops] + secrets: [DB_PASSWORD] + variables: [REGION] + environment_url: https://app.example.com +`) + if got := cfg.EnvironmentNames(); len(got) != 2 || got[0] != "dev" || got[1] != "prod" { + t.Fatalf("EnvironmentNames = %v, want [dev prod]", got) + } + // The config-free bare-string entry carries no settings. + dev, ok := cfg.EnvConfig("dev") + if !ok || !dev.isZero() { + t.Fatalf("dev entry should be config-free, got %+v (ok=%v)", dev, ok) + } + prod, ok := cfg.EnvConfig("prod") + if !ok { + t.Fatal("prod entry missing") + } + if prod.GHAEnvironment != "production" { + t.Errorf("gha_environment = %q, want production", prod.GHAEnvironment) + } + if prod.WaitTimerMinutes() != 10 { + t.Errorf("wait_timer = %d, want 10", prod.WaitTimerMinutes()) + } + if prod.BranchPolicy != "protected" { + t.Errorf("branch_policy = %q, want protected", prod.BranchPolicy) + } + if prod.EnvironmentURL != "https://app.example.com" { + t.Errorf("environment_url = %q", prod.EnvironmentURL) + } + if len(prod.RequiredReviewers) != 2 || len(prod.Secrets) != 1 || len(prod.Variables) != 1 { + t.Errorf("reviewers/secrets/variables not parsed: %+v", prod) + } + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } +} + +// TestEnvironmentsFold_BareStringSugar proves the bare-string list still parses, +// yielding config-free entries in order. +func TestEnvironmentsFold_BareStringSugar(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, staging, prod] +`) + if got := cfg.EnvironmentNames(); strings.Join(got, ",") != "dev,staging,prod" { + t.Fatalf("EnvironmentNames = %v", got) + } + for _, e := range cfg.Environments { + if !e.isZero() || e.Role != "" { + t.Errorf("bare entry %q should be config-free and role-free", e.Name) + } + } + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } +} + +// TestEnvironmentsFold_RejectsLegacyEnvironmentConfig proves the removed +// top-level environment_config map is now an unknown key with a did-you-mean that +// points at the environments entries. +func TestEnvironmentsFold_RejectsLegacyEnvironmentConfig(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +environment_config: + prod: + gha_environment: production +`) + errs := Validate(cfg) + if !hasErrContaining(errs, `unknown field "environment_config"; did you mean "environments"`) { + t.Fatalf("expected environment_config did-you-mean pointing at environments, got %v", errs) + } +} + +// TestEnvironmentsFold_RejectsUnknownEntryField proves an unmodeled key on an +// environments entry is a hard error with a suggestion. +func TestEnvironmentsFold_RejectsUnknownEntryField(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: + - name: prod + gha_env: production +`) + errs := Validate(cfg) + if !hasErrContaining(errs, `environments[0] has unknown field "gha_env"`) { + t.Fatalf("expected unknown-entry-field error, got %v", errs) + } +} + +// TestEnvironmentsFold_DuplicateAndEmptyNames proves duplicate and empty +// environment names are rejected. +func TestEnvironmentsFold_DuplicateAndEmptyNames(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: + - dev + - dev +`) + if errs := Validate(cfg); !hasErrContaining(errs, "duplicate environment name: dev") { + t.Fatalf("expected duplicate-name error, got %v", errs) + } +} + +// TestEnvironmentsRole_OverridesPositionalDefault proves an explicit role moves +// the release/prerelease markers while an unset role keeps the index-based +// default (last = release, second-from-last = prerelease). +func TestEnvironmentsRole_OverridesPositionalDefault(t *testing.T) { + // Positional default: no roles declared. + def := parseInline(t, ` +trunk_branch: main +environments: [dev, staging, prod] +`) + if got := def.ReleaseEnvironment(); got != "prod" { + t.Errorf("default release env = %q, want prod (last)", got) + } + if got := def.PrereleaseEnvironment(); got != "staging" { + t.Errorf("default prerelease env = %q, want staging (second-from-last)", got) + } + + // Explicit roles override position: release is NOT the last entry here. + roled := parseInline(t, ` +trunk_branch: main +environments: + - name: dev + - name: staging + role: release + - name: prod + role: prerelease +`) + if got := roled.ReleaseEnvironment(); got != "staging" { + t.Errorf("role release env = %q, want staging", got) + } + if got := roled.PrereleaseEnvironment(); got != "prod" { + t.Errorf("role prerelease env = %q, want prod", got) + } + if errs := Validate(roled); len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + + // The explicit role flows into the promotion graph: promoting to staging is + // the release crossing, not promoting to the last entry (prod). + var relToStaging, relToProd bool + for _, opt := range roled.GetAllDirectPromotionOptions() { + if opt.ToEnv == "staging" && opt.IsRelease { + relToStaging = true + } + if opt.ToEnv == "prod" && opt.IsRelease { + relToProd = true + } + } + if !relToStaging { + t.Error("promotion to role:release env (staging) must be a release crossing") + } + if relToProd { + t.Error("promotion to prod must NOT be a release crossing when role:release is elsewhere") + } +} + +// TestEnvironmentsRole_RejectsInvalidAndDuplicate proves an unknown role value +// and duplicate release/prerelease roles are rejected. +func TestEnvironmentsRole_RejectsInvalidAndDuplicate(t *testing.T) { + bad := parseInline(t, ` +trunk_branch: main +environments: + - name: dev + role: staging +`) + if errs := Validate(bad); !hasErrContaining(errs, "role must be one of") { + t.Fatalf("expected invalid-role error, got %v", errs) + } + + dup := parseInline(t, ` +trunk_branch: main +environments: + - name: dev + role: release + - name: prod + role: release +`) + if errs := Validate(dup); !hasErrContaining(errs, "at most one environment may declare role: release") { + t.Fatalf("expected duplicate-release-role error, got %v", errs) + } +} + +// TestEnvironmentsFold_ComponentSubsetStillValidates proves a component can still +// narrow the ladder via either bare strings or objects, and the resolved subset +// whole-replaces the inherited list (settings and all), preserving the pre-fold +// component environments behavior. +func TestEnvironmentsFold_ComponentSubsetStillValidates(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: + - dev + - name: staging + gha_environment: staging-shared + - name: prod + gha_environment: production +components: + worker: + path: services/worker + tag_grammar: + prefix: worker- + environments: + - dev + - name: prod + gha_environment: worker-prod +`) + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + rc, err := cfg.ResolveComponent("worker") + if err != nil { + t.Fatalf("ResolveComponent: %v", err) + } + if got := rc.Config.EnvironmentNames(); strings.Join(got, ",") != "dev,prod" { + t.Fatalf("component ladder = %v, want [dev prod]", got) + } + // Whole-replace: the component's prod entry carries ITS gha_environment, and + // staging (dropped by the subset) is gone rather than lingering. + prod, ok := rc.Config.EnvConfig("prod") + if !ok || prod.GHAEnvironment != "worker-prod" { + t.Errorf("component prod gha_environment = %q, want worker-prod", prod.GHAEnvironment) + } + if _, ok := rc.Config.EnvConfig("staging"); ok { + t.Error("staging must not survive the component subset whole-replace") + } +} + +// TestEnvironmentsFold_MarshalRoundTrip proves parse-then-marshal is faithful: a +// config-free entry collapses back to the bare-string sugar, while a configured +// entry marshals to a mapping. +func TestEnvironmentsFold_MarshalRoundTrip(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: + - dev + - name: prod + gha_environment: production + role: release +`) + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + if !strings.Contains(s, "- dev\n") { + t.Errorf("config-free entry should marshal to bare string, got:\n%s", s) + } + if !strings.Contains(s, "name: prod") || !strings.Contains(s, "gha_environment: production") || !strings.Contains(s, "role: release") { + t.Errorf("configured entry should marshal to a mapping, got:\n%s", s) + } + // Round trip is stable. + var back TrunkConfig + if err := yaml.Unmarshal(out, &back); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if strings.Join(back.EnvironmentNames(), ",") != "dev,prod" { + t.Fatalf("round-trip names = %v", back.EnvironmentNames()) + } + if p, _ := back.EnvConfig("prod"); p.GHAEnvironment != "production" { + t.Errorf("round-trip lost prod config: %+v", p) + } +} diff --git a/internal/config/native_deployments_test.go b/internal/config/native_deployments_test.go index 054abf70..dbf87f6c 100644 --- a/internal/config/native_deployments_test.go +++ b/internal/config/native_deployments_test.go @@ -7,21 +7,20 @@ import "testing" func TestParseDeployments(t *testing.T) { cfg := parseInline(t, ` trunk_branch: main -environments: [production] +environments: + - name: production + environment_url: "https://app.example.com" deployments: enabled: true keep_prior_active: true -environment_config: - production: - environment_url: "https://app.example.com" `) if !cfg.Deployments.IsEnabled() || !cfg.Deployments.KeepsPriorActive() { t.Fatalf("deployments: %#v", cfg.Deployments) } - ec, ok := cfg.EnvironmentConfig["production"] - if !ok { - t.Fatalf("environment_config missing production: %#v", cfg.EnvironmentConfig) + if len(cfg.Environments) != 1 || cfg.Environments[0].Name != "production" { + t.Fatalf("environments missing production: %#v", cfg.Environments) } + ec := cfg.Environments[0].EnvironmentConfig if ec.EnvironmentURL != "https://app.example.com" { t.Fatalf("environment_url: %q", ec.EnvironmentURL) } @@ -34,11 +33,10 @@ func TestDeploymentsValidatesAtCurrentSchemaVersion(t *testing.T) { cfg := &TrunkConfig{ SchemaVersion: CurrentSchemaVersion, TrunkBranch: "main", - Environments: []string{"production"}, - Deployments: &DeploymentsConfig{Enabled: boolPtr(true), KeepPriorActive: boolPtr(true)}, - EnvironmentConfig: map[string]EnvironmentConfig{ - "production": {EnvironmentURL: "https://app.example.com"}, + Environments: []EnvironmentEntry{ + {Name: "production", EnvironmentConfig: EnvironmentConfig{EnvironmentURL: "https://app.example.com"}}, }, + Deployments: &DeploymentsConfig{Enabled: boolPtr(true), KeepPriorActive: boolPtr(true)}, } for _, e := range Validate(cfg) { t.Fatalf("unexpected validation error for deployments at current schema version: %s", e) diff --git a/internal/config/parse.go b/internal/config/parse.go index d7dae2fe..cb4b91f1 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -75,7 +75,7 @@ func ParseManifestBytes(data []byte, key string) (*CICDFile, error) { // Ensure all environments have state entries if file.Config != nil { file.Config.ManifestKey = key - for _, env := range file.Config.Environments { + for _, env := range file.Config.EnvironmentNames() { if file.State[env] == nil { file.State[env] = &EnvState{} } @@ -176,12 +176,16 @@ func Validate(cfg *TrunkConfig) []string { // Empty environments is valid - means pre-release -> release only (no deployments) envSet := make(map[string]bool) - for i, env := range cfg.Environments { + envNames := cfg.EnvironmentNames() + for i, env := range envNames { envSet[env] = true // Environment names key job IDs and ${{ }} expression references, so they // must be job-ID-safe. errors = append(errors, validateJobIDSafeName(fmt.Sprintf("environments[%d]", i), env)...) } + // Fold-specific structural rules for the environments list of objects: names + // must be non-empty and unique, and any declared role must be a known value. + errors = append(errors, validateEnvironments(cfg)...) // Build name sets for each section (builds and deploys can share names) buildNames := make(map[string]bool) @@ -247,7 +251,7 @@ func Validate(cfg *TrunkConfig) []string { // Validate env_inputs keys match top-level environments for envKey := range b.EnvInputs { if !envSet[envKey] { - errors = append(errors, fmt.Sprintf("builds[%d].env_inputs has key '%s' which is not in environments %v", i, envKey, cfg.Environments)) + errors = append(errors, fmt.Sprintf("builds[%d].env_inputs has key '%s' which is not in environments %v", i, envKey, envNames)) } } } @@ -275,7 +279,7 @@ func Validate(cfg *TrunkConfig) []string { errors = append(errors, validateJobControlFields(fmt.Sprintf("deploys[%d]", i), isReusable, d.RunsOn, d.Concurrency)...) errors = append(errors, validatePermissions(fmt.Sprintf("deploys[%d]", i), d.Permissions)...) errors = append(errors, validateSecrets(fmt.Sprintf("deploys[%d]", i), d.Secrets)...) - errors = append(errors, validateRollout(fmt.Sprintf("deploys[%d]", i), d.Rollout, cfg.Environments)...) + errors = append(errors, validateRollout(fmt.Sprintf("deploys[%d]", i), d.Rollout, envNames)...) errors = append(errors, validateDeployTarget(fmt.Sprintf("deploys[%d]", i), d.DeployTarget)...) // Validate run_policy @@ -311,7 +315,7 @@ func Validate(cfg *TrunkConfig) []string { // Validate env_inputs keys match top-level environments for envKey := range d.EnvInputs { if !envSet[envKey] { - errors = append(errors, fmt.Sprintf("deploys[%d].env_inputs has key '%s' which is not in environments %v", i, envKey, cfg.Environments)) + errors = append(errors, fmt.Sprintf("deploys[%d].env_inputs has key '%s' which is not in environments %v", i, envKey, envNames)) } } } @@ -406,7 +410,7 @@ func Validate(cfg *TrunkConfig) []string { errors = append(errors, validateJobControlFields(prefix, true, d.RunsOn, d.Concurrency)...) errors = append(errors, validatePermissions(prefix, d.Permissions)...) errors = append(errors, validateSecrets(prefix, d.Secrets)...) - errors = append(errors, validateRollout(prefix, d.Rollout, cfg.Environments)...) + errors = append(errors, validateRollout(prefix, d.Rollout, envNames)...) errors = append(errors, validateDeployTarget(prefix, d.DeployTarget)...) for _, dep := range d.OptionalDependsOn { if _, err := cfg.ResolveDependency(dep, CallbackTypeExternal); err != nil { diff --git a/internal/config/parse_test.go b/internal/config/parse_test.go index 467a191a..b30b8033 100644 --- a/internal/config/parse_test.go +++ b/internal/config/parse_test.go @@ -212,7 +212,7 @@ func TestValidate(t *testing.T) { name: "valid config", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml"}, }, @@ -232,7 +232,7 @@ func TestValidate(t *testing.T) { { name: "missing build name", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Workflow: ".github/workflows/build.yaml"}, }, @@ -242,7 +242,7 @@ func TestValidate(t *testing.T) { { name: "missing build workflow", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "app"}, }, @@ -252,7 +252,7 @@ func TestValidate(t *testing.T) { { name: "duplicate build names", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "app", Workflow: ".github/workflows/build1.yaml"}, {Name: "app", Workflow: ".github/workflows/build2.yaml"}, @@ -263,7 +263,7 @@ func TestValidate(t *testing.T) { { name: "missing deploy name", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Deploys: []DeployConfig{ {Workflow: ".github/workflows/deploy.yaml"}, }, @@ -273,7 +273,7 @@ func TestValidate(t *testing.T) { { name: "missing deploy workflow", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Deploys: []DeployConfig{ {Name: "cdk"}, }, @@ -283,7 +283,7 @@ func TestValidate(t *testing.T) { { name: "duplicate deploy names", config: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Deploys: []DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy1.yaml"}, {Name: "cdk", Workflow: ".github/workflows/deploy2.yaml"}, @@ -294,7 +294,7 @@ func TestValidate(t *testing.T) { { name: "valid env_inputs matching environments", config: TrunkConfig{ - Environments: []string{"dev", "test", "prod"}, + Environments: EnvNames("dev", "test", "prod"), Deploys: []DeployConfig{ { Name: "app", @@ -311,7 +311,7 @@ func TestValidate(t *testing.T) { { name: "invalid env_inputs key not in environments", config: TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), Deploys: []DeployConfig{ { Name: "app", @@ -435,7 +435,7 @@ func TestParse_NewSchemaFields(t *testing.T) { if len(cfg.Environments) != 2 { t.Errorf("len(Environments) = %d, want 2", len(cfg.Environments)) } - if cfg.Environments[0] != "dev" || cfg.Environments[1] != "prod" { + if cfg.Environments[0].Name != "dev" || cfg.Environments[1].Name != "prod" { t.Errorf("Environments = %v, want [dev prod]", cfg.Environments) } @@ -566,7 +566,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "valid run_policy values", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", RunPolicy: "default"}, {Name: "b", Workflow: "w.yaml", RunPolicy: "always"}, @@ -578,7 +578,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "invalid run_policy", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", RunPolicy: "invalid"}, }, @@ -588,7 +588,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "invalid on_failure", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", OnFailure: "invalid"}, }, @@ -598,7 +598,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "retries out of range", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", Retries: 5}, }, @@ -608,7 +608,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "invalid depends_on reference", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", DependsOn: []string{"nonexistent"}}, }, @@ -618,7 +618,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "build env_inputs key not in environments", cfg: TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", EnvInputs: map[string]map[string]interface{}{ "staging": {"key": "value"}, @@ -630,7 +630,7 @@ func TestValidate_NewFields(t *testing.T) { { name: "circular dependency", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "a", Workflow: "w.yaml", DependsOn: []string{"b"}}, {Name: "b", Workflow: "w.yaml", DependsOn: []string{"a"}}, @@ -767,7 +767,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "valid release.tag reference", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Release: &ReleaseConfig{Tag: "goreleaser.tag"}, Builds: []BuildConfig{ {Name: "goreleaser", Workflow: "w.yaml"}, @@ -778,7 +778,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "invalid release.tag - unknown callback", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Release: &ReleaseConfig{Tag: "nonexistent.tag"}, Builds: []BuildConfig{ {Name: "app", Workflow: "w.yaml"}, @@ -789,7 +789,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "invalid release.tag - bad format", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Release: &ReleaseConfig{Tag: "invalid"}, Builds: []BuildConfig{ {Name: "app", Workflow: "w.yaml"}, @@ -800,7 +800,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "release.tag with deploy callback", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Release: &ReleaseConfig{Tag: "release.version"}, Deploys: []DeployConfig{ {Name: "release", Workflow: "w.yaml"}, @@ -811,7 +811,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "deploy depends_on references valid build", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "app", Workflow: "w.yaml", Triggers: []string{"src/**"}}, }, @@ -824,7 +824,7 @@ func TestValidate_ReleaseTag(t *testing.T) { { name: "deploy depends_on references unknown build", cfg: TrunkConfig{ - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{ {Name: "app", Workflow: "w.yaml", Triggers: []string{"src/**"}}, }, @@ -882,7 +882,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "build name with dot rejected", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{{Name: "app.web", Workflow: ".github/workflows/build.yaml"}}, }, wantErr: `builds[0].name "app.web"`, @@ -891,7 +891,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "build name with space rejected", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Builds: []BuildConfig{{Name: "my app", Workflow: ".github/workflows/build.yaml"}}, }, wantErr: `builds[0].name "my app"`, @@ -900,7 +900,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "deploy name with slash rejected", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: EnvNames("dev"), Deploys: []DeployConfig{{Name: "svc/api", Workflow: ".github/workflows/deploy.yaml"}}, }, wantErr: `deploys[0].name "svc/api"`, @@ -909,7 +909,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "external deploy name with dot rejected", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: EnvNames("dev"), External: []ExternalRepoConfig{{ Repo: "owner/repo", Deploys: []ExternalDeployConfig{{Name: "svc.api", Workflow: ".github/workflows/deploy.yaml"}}, @@ -921,7 +921,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "environment name with dot rejected", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"us.east"}, + Environments: EnvNames("us.east"), }, wantErr: `environments[0] "us.east"`, }, @@ -929,7 +929,7 @@ func TestValidate_JobIDSafeNames(t *testing.T) { name: "valid names: hyphen, uppercase, leading digit, underscore", config: TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev-1", "Prod", "2nd", "us_west"}, + Environments: EnvNames("dev-1", "Prod", "2nd", "us_west"), Builds: []BuildConfig{ {Name: "my-app", Workflow: ".github/workflows/build.yaml"}, {Name: "MyApp", Workflow: ".github/workflows/build.yaml"}, @@ -1025,7 +1025,7 @@ func TestExternalDeployOnUpdate_Validation(t *testing.T) { base := func() *TrunkConfig { return &TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), External: []ExternalRepoConfig{ { Repo: "example/cdk-infra", diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 7d96c4ce..36d01197 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -496,6 +496,136 @@ func (e EnvironmentConfig) WaitTimerMinutes() int { return *e.WaitTimer } +// isZero reports whether every per-environment setting is at its zero value, so +// an environment carries no inline configuration. It backs the environments +// entry MarshalYAML, which collapses a config-free entry back to the bare-string +// sugar for a faithful round trip. +func (e EnvironmentConfig) isZero() bool { + return e.GHAEnvironment == "" && + len(e.RequiredReviewers) == 0 && + e.WaitTimer == nil && + e.BranchPolicy == "" && + len(e.BranchPatterns) == 0 && + len(e.TagPatterns) == 0 && + len(e.Secrets) == 0 && + len(e.Variables) == 0 && + e.EnvironmentURL == "" +} + +// Environment role constants. A role explicitly declares which promotion stage +// an environment plays, overriding the positional default (last entry = +// release, second-from-last = prerelease). Only these two roles carry behavior; +// an unset role keeps the index-based default so a manifest that declares no +// role behaves exactly as before. +const ( + // EnvRolePrerelease marks the environment whose promotion cuts the + // prerelease, overriding the second-from-last positional default. + EnvRolePrerelease = "prerelease" + // EnvRoleRelease marks the environment whose promotion publishes the + // release, overriding the last-entry positional default. + EnvRoleRelease = "release" +) + +// EnvironmentEntry is one entry in the ordered environments: list. The list is +// the single source of truth for both the promotion ladder (its order) and each +// environment's settings (its inline config), folding what used to be the +// separate environments list and environment_config map into one shape. +// +// An entry accepts two YAML forms, reconciled by UnmarshalYAML: +// +// - a bare string ("- dev") - sugar for an environment with no extra config; +// - a mapping ("- {name: dev, wait_timer: 5, ...}") carrying the name, an +// optional role, and the inline per-environment settings. +// +// The embedded EnvironmentConfig carries the protection and native-Environment +// settings (gha_environment, required_reviewers, wait_timer, branch_policy, +// branch_patterns, tag_patterns, secrets, variables, environment_url). Because +// the entry has a custom UnmarshalYAML it is a replace-leaf under component deep +// merge: a component that sets environments: whole-replaces the inherited list +// (settings and all), preserving the pre-fold whole-replace of the environments +// slice. +type EnvironmentEntry struct { + // Name is the environment name. It keys job IDs and ${{ }} expression + // references and defines this entry's rung on the promotion ladder. + Name string `yaml:"name" json:"name"` + // Role optionally declares this environment's promotion stage + // (EnvRolePrerelease or EnvRoleRelease), overriding the positional default. + // Empty keeps the index-based default. + Role string `yaml:"role,omitempty" json:"role,omitempty"` + // EnvironmentConfig is the inline per-environment settings block. Its fields + // are promoted onto the entry's YAML mapping and JSON object (no nesting). + EnvironmentConfig `yaml:",inline" json:",inline"` + // Extra captures any key on an environment mapping that is not a modeled + // field, mirroring the top-level and per-callback catch-alls. A populated + // Extra is rejected by validation with a did-you-mean suggestion. Never + // serialized. + Extra map[string]any `yaml:",inline" json:"-"` +} + +// EnvNames builds an ordered environments list from bare names, the programmatic +// equivalent of the "- dev" string sugar: each name becomes a config-free entry. +// It is the constructor for the common case of an environment ladder that needs +// no per-environment settings. +func EnvNames(names ...string) []EnvironmentEntry { + if len(names) == 0 { + return nil + } + entries := make([]EnvironmentEntry, len(names)) + for i, n := range names { + entries[i] = EnvironmentEntry{Name: n} + } + return entries +} + +// MarshalYAML emits the faithful inverse of UnmarshalYAML: a config-free entry +// (no role, no inline settings) marshals back to the bare-string sugar, and a +// configured entry marshals to a mapping with name, role, and the inline +// settings. This keeps a parse-then-marshal round trip byte-stable for the +// common bare-name ladder rather than rewriting every "- dev" as "- name: dev". +func (e EnvironmentEntry) MarshalYAML() (interface{}, error) { + if e.Role == "" && e.isZero() { + return e.Name, nil + } + type entryFields struct { + Name string `yaml:"name"` + Role string `yaml:"role,omitempty"` + EnvironmentConfig `yaml:",inline"` + } + return entryFields{Name: e.Name, Role: e.Role, EnvironmentConfig: e.EnvironmentConfig}, nil +} + +// UnmarshalYAML reconciles the bare-string sugar and the object form into one +// EnvironmentEntry. A scalar node is the environment name; a mapping node +// decodes name, role, the inline settings, and any unknown keys (captured in +// Extra for the strictness walk). +func (e *EnvironmentEntry) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + var name string + if err := value.Decode(&name); err != nil { + return err + } + e.Name = name + return nil + } + // A distinct type strips this method so Decode does not recurse; its fields + // mirror EnvironmentEntry so the inline settings and catch-all decode intact. + type entryFields struct { + Name string `yaml:"name"` + Role string `yaml:"role,omitempty"` + EnvironmentConfig `yaml:",inline"` + Extra map[string]any `yaml:",inline"` + } + var ef entryFields + if err := value.Decode(&ef); err != nil { + return err + } + e.Name = ef.Name + e.Role = ef.Role + e.EnvironmentConfig = ef.EnvironmentConfig + e.Extra = ef.Extra + return nil +} + // Environment branch-policy mode constants. They map onto GitHub's // deployment_branch_policy model: protected_branches, custom_branch_policies, // or null (all branches). diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 7ce4bc50..47d54584 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -173,7 +173,10 @@ deploys: func TestParseConfigLevelFields(t *testing.T) { cfg := parseInline(t, ` -environments: [dev, prod] +environments: + - dev + - name: prod + gha_environment: production runs_on: ubuntu-latest job_timeout_minutes: 30 pin_mode: sha @@ -206,9 +209,6 @@ merge_queue: telemetry: enabled: false adapter: none -environment_config: - prod: - gha_environment: production `) if cfg.GetPinMode() != "sha" || cfg.ActionPins["actions/checkout"] != "v4.2.2" { t.Fatalf("pin fields: %s %v", cfg.PinMode, cfg.ActionPins) @@ -226,8 +226,14 @@ environment_config: if !cfg.PRPreview.IsEnabled() || !cfg.ValidateCheck.IsEnabled() || !cfg.MergeQueue.Enabled { t.Fatalf("pr lanes: %#v %#v %#v", cfg.PRPreview, cfg.ValidateCheck, cfg.MergeQueue) } - if cfg.Telemetry.Adapter != "none" || cfg.EnvironmentConfig["prod"].GHAEnvironment != "production" { - t.Fatalf("telemetry/env_config: %#v %#v", cfg.Telemetry, cfg.EnvironmentConfig) + var prodGHAEnvironment string + for _, e := range cfg.Environments { + if e.Name == "prod" { + prodGHAEnvironment = e.GHAEnvironment + } + } + if cfg.Telemetry.Adapter != "none" || prodGHAEnvironment != "production" { + t.Fatalf("telemetry/env_config: %#v %#v", cfg.Telemetry, cfg.Environments) } } @@ -737,15 +743,18 @@ dispatch_inputs: t.Fatalf("expected dotted/hyphenated options to pass, got %v", errs) } }) - t.Run("environment_config unknown env rejected", func(t *testing.T) { + t.Run("removed environment_config block rejected with a did-you-mean", func(t *testing.T) { + // environment_config was folded into the environments: list; the + // standalone block is now an unknown top-level field with a + // did-you-mean pointing at environments. cfg := parseInline(t, ` environments: [dev] environment_config: prod: gha_environment: production `) - if errs := Validate(cfg); !hasErrContaining(errs, "not in environments") { - t.Fatalf("expected env_config rejection, got %v", errs) + if errs := Validate(cfg); !hasErrContaining(errs, `unknown field "environment_config"; did you mean "environments"`) { + t.Fatalf("expected environment_config did-you-mean rejection, got %v", errs) } }) } diff --git a/internal/config/strict.go b/internal/config/strict.go index f2142cbb..61d33cfe 100644 --- a/internal/config/strict.go +++ b/internal/config/strict.go @@ -24,6 +24,17 @@ func knownComponentFields() []string { return knownYAMLFields(reflect.TypeOf(ComponentConfig{})) } +// knownEnvironmentEntryFields returns the modeled yaml keys accepted on an +// environments entry: the entry's own name and role plus every inline +// per-environment setting promoted from EnvironmentConfig (the folded former +// environment_config block). It backs the entry-level did-you-mean suggestion. +func knownEnvironmentEntryFields() []string { + fields := []string{"name", "role"} + fields = append(fields, knownYAMLFields(reflect.TypeOf(EnvironmentConfig{}))...) + sort.Strings(fields) + return fields +} + // knownYAMLFields returns the modeled yaml key names for a struct type, derived // from its yaml struct tags. The inline catch-all (yaml:",inline") and any // yaml:"-" field are excluded. Reflecting the tags keeps every known-field set @@ -85,6 +96,11 @@ var legacyCallbackFieldRenames = map[string]string{ // the nested destination, since it is not a single top-level field name. var legacyFieldRenames = map[string]string{ "tag_prefix": "tag_grammar.prefix", + // The separate environment_config map was folded into the environments list: + // per-environment settings now ride on each environments entry as inline keys + // ({name: dev, wait_timer: 5, ...}). Point a stale environment_config: at the + // list rather than at the nearest field by edit distance. + "environment_config": "environments", } // fieldSuggestion returns the best did-you-mean replacement for an unknown key: diff --git a/internal/config/types.go b/internal/config/types.go index 4e971b26..863c1d92 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -123,7 +123,13 @@ type TrunkConfig struct { // workflow_dispatch, letting a maintainer-owned gate decide when a release // candidate is cut. Opt-in; repos that do not set it keep push triggers. ReleaseTrigger string `yaml:"release_trigger,omitempty" json:"release_trigger,omitempty"` - Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` // Empty = no-environment setup (library/CLI projects) + // Environments is the ordered promotion ladder, folded into one list of + // objects: each entry is a bare string ("- dev", sugar for a config-free + // environment) or a mapping ("- {name: dev, role: ..., wait_timer: ...}") + // carrying that environment's inline settings. Order defines the ladder; + // per-environment settings live on the entry (the former environment_config + // map). Empty = no-environment setup (library/CLI projects). + Environments []EnvironmentEntry `yaml:"environments,omitempty" json:"environments,omitempty"` CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) // CLIVersionSHA is the 40-hex commit SHA that cli_version resolves to. When // set and pin_mode is sha, generated setup-cli self-action refs are pinned to @@ -183,7 +189,6 @@ type TrunkConfig struct { PinMode string `yaml:"pin_mode,omitempty" json:"pin_mode,omitempty"` // tag | sha (default tag) ActionPins map[string]string `yaml:"action_pins,omitempty" json:"action_pins,omitempty"` Telemetry *TelemetryConfig `yaml:"telemetry,omitempty" json:"telemetry,omitempty"` - EnvironmentConfig map[string]EnvironmentConfig `yaml:"environment_config,omitempty" json:"environment_config,omitempty"` // Components reserves the shape for independently versioned components sharing // one manifest (#176). v1 contract: parse + structural validation only; no // generator, state, or runtime behavior is attached. Absent by default. @@ -964,23 +969,82 @@ func (c *TrunkConfig) GetPromotionModes() []PromotionMode { return []PromotionMode{ModeDefault, ModeCascade} } +// EnvironmentNames returns the ordered environment names, the promotion ladder +// as a plain string slice. It is the accessor every positional consumer uses now +// that the environments list holds objects rather than bare strings. +func (c *TrunkConfig) EnvironmentNames() []string { + if len(c.Environments) == 0 { + return nil + } + names := make([]string, len(c.Environments)) + for i, e := range c.Environments { + names[i] = e.Name + } + return names +} + +// EnvConfig returns the inline per-environment settings for the named +// environment and whether that environment is declared. It replaces the former +// environment_config map lookup now that settings live on each environments +// entry. +func (c *TrunkConfig) EnvConfig(name string) (EnvironmentConfig, bool) { + for _, e := range c.Environments { + if e.Name == name { + return e.EnvironmentConfig, true + } + } + return EnvironmentConfig{}, false +} + +// ReleaseEnvironment returns the environment whose promotion publishes the +// release: the entry that declares role: release, or the last entry when no +// role is set (the index-based default). Empty when no environments exist. +func (c *TrunkConfig) ReleaseEnvironment() string { + for _, e := range c.Environments { + if e.Role == EnvRoleRelease { + return e.Name + } + } + if len(c.Environments) == 0 { + return "" + } + return c.Environments[len(c.Environments)-1].Name +} + +// PrereleaseEnvironment returns the environment whose promotion cuts the +// prerelease: the entry that declares role: prerelease, or the second-from-last +// entry when no role is set (the index-based default). Empty when fewer than two +// environments exist. +func (c *TrunkConfig) PrereleaseEnvironment() string { + for _, e := range c.Environments { + if e.Role == EnvRolePrerelease { + return e.Name + } + } + if len(c.Environments) < 2 { + return "" + } + return c.Environments[len(c.Environments)-2].Name +} + // GetAllDirectPromotionOptions returns all direct promotion options. -// Release states are determined by position, not by fake "release" environment: -// - Promotion to second-from-top env (e.g., uat) = prerelease state -// - Promotion to top env (e.g., prod) = released state +// Release and prerelease stages default to position (last env = release, +// second-from-last = prerelease) but an explicit role: on an environments entry +// overrides that default. Promotion to the release environment triggers the +// released state; promotion to the prerelease environment triggers prerelease. // // For 4 envs [dev, test, uat, prod], generates: // - dev-to-test, dev-to-uat, test-to-uat (env-to-env, uat promotions trigger prerelease) // - dev-to-prod, test-to-prod, uat-to-prod (includes prod deployment, triggers released) func (c *TrunkConfig) GetAllDirectPromotionOptions() []PromotionOption { - envs := c.Environments + envs := c.EnvironmentNames() if len(envs) < 2 { return nil } var options []PromotionOption - prodEnv := envs[len(envs)-1] - prereleaseEnv := envs[len(envs)-2] // Second from top = prerelease environment + prodEnv := c.ReleaseEnvironment() // role: release, else last entry + prereleaseEnv := c.PrereleaseEnvironment() // role: prerelease, else second from top // Generate from each environment (except prod) for i := 0; i < len(envs)-1; i++ { @@ -1032,21 +1096,25 @@ func (c *TrunkConfig) IsFirstEnvironment(env string) bool { if len(c.Environments) == 0 { return false } - return c.Environments[0] == env + return c.Environments[0].Name == env } -// IsLastEnvironment returns true if env is the last (production) +// IsLastEnvironment returns true if env is the last entry by position. This is a +// positional query, NOT the release-role query: with an explicit role: release on +// a non-last entry the release environment differs from the last one, so callers +// deciding the release stage must use ReleaseEnvironment, not this. It has no +// production consumer today; the ladder-position semantics are kept for tests. func (c *TrunkConfig) IsLastEnvironment(env string) bool { if len(c.Environments) == 0 { return false } - return c.Environments[len(c.Environments)-1] == env + return c.Environments[len(c.Environments)-1].Name == env } // GetEnvironmentIndex returns the index of the environment, -1 if not found func (c *TrunkConfig) GetEnvironmentIndex(env string) int { for i, e := range c.Environments { - if e == env { + if e.Name == env { return i } } @@ -1059,7 +1127,7 @@ func (c *TrunkConfig) GetNextEnvironment(env string) string { if idx == -1 || idx == len(c.Environments)-1 { return "" } - return c.Environments[idx+1] + return c.Environments[idx+1].Name } // GetEnvironmentsInRange returns all environments from start to end (inclusive) @@ -1074,7 +1142,7 @@ func (c *TrunkConfig) GetEnvironmentsInRange(start, end string) []string { result := make([]string, 0, endIdx-startIdx+1) for i := startIdx; i <= endIdx; i++ { - result = append(result, c.Environments[i]) + result = append(result, c.Environments[i].Name) } return result } @@ -1281,7 +1349,7 @@ type ComponentConfig struct { // (prerelease_token, separator, dryrun_token) deep-merge onto the shared // top-level tag_grammar, so a component setting only prefix inherits the rest. TagGrammar *TagGrammarConfig `yaml:"tag_grammar,omitempty" json:"tag_grammar,omitempty"` - Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` + Environments []EnvironmentEntry `yaml:"environments,omitempty" json:"environments,omitempty"` ReleaseTrigger string `yaml:"release_trigger,omitempty" json:"release_trigger,omitempty"` AllowBreakingChanges *bool `yaml:"allow_breaking_changes,omitempty" json:"allow_breaking_changes,omitempty"` Validate *ValidateConfig `yaml:"validate,omitempty" json:"validate,omitempty"` @@ -1301,7 +1369,6 @@ type ComponentConfig struct { ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"` Rollback *RollbackConfig `yaml:"rollback,omitempty" json:"rollback,omitempty"` Deployments *DeploymentsConfig `yaml:"deployments,omitempty" json:"deployments,omitempty"` - EnvironmentConfig map[string]EnvironmentConfig `yaml:"environment_config,omitempty" json:"environment_config,omitempty"` Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // ExtraPaths lists repo-relative globs beyond this component's own path that both // fire its orchestrate workflow and count toward its version bump, so a change to diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 3ebdf542..864d92d2 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -9,7 +9,7 @@ import ( ) func TestGetPromotionModes(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "test", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "test", "prod")} modes := cfg.GetPromotionModes() assert.Len(t, modes, 2) @@ -22,7 +22,7 @@ func TestGetCascadeTargets(t *testing.T) { // For [dev, test, uat, prod] (4 envs): // - Direct env-to-env: dev-to-test, dev-to-uat, dev-to-prod, test-to-uat, test-to-prod, uat-to-prod = 6 - cfg := &TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "test", "uat", "prod")} options := cfg.GetCascadeTargets() // 6 direct promotions @@ -46,7 +46,7 @@ func TestGetAllDirectPromotionOptions(t *testing.T) { // - uat (second-from-top) = prerelease env // - prod (top) = release env - cfg := &TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "test", "uat", "prod")} options := cfg.GetAllDirectPromotionOptions() assert.Len(t, options, 6) @@ -90,7 +90,7 @@ func TestGetAllDirectPromotionOptions_TwoEnvs(t *testing.T) { // // With 2 envs, prod is both prerelease AND release env (position-based) - cfg := &TrunkConfig{Environments: []string{"dev", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "prod")} options := cfg.GetAllDirectPromotionOptions() assert.Len(t, options, 1) @@ -103,28 +103,28 @@ func TestGetAllDirectPromotionOptions_TwoEnvs(t *testing.T) { func TestGetAllDirectPromotionOptions_SingleEnv(t *testing.T) { // For 1 env, returns nil (no promotions possible) - cfg := &TrunkConfig{Environments: []string{"prod"}} + cfg := &TrunkConfig{Environments: EnvNames("prod")} options := cfg.GetAllDirectPromotionOptions() assert.Nil(t, options) } func TestIsSingleEnvironment(t *testing.T) { - single := &TrunkConfig{Environments: []string{"prod"}} + single := &TrunkConfig{Environments: EnvNames("prod")} assert.True(t, single.IsSingleEnvironment()) - two := &TrunkConfig{Environments: []string{"dev", "prod"}} + two := &TrunkConfig{Environments: EnvNames("dev", "prod")} assert.False(t, two.IsSingleEnvironment()) - four := &TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}} + four := &TrunkConfig{Environments: EnvNames("dev", "test", "uat", "prod")} assert.False(t, four.IsSingleEnvironment()) - empty := &TrunkConfig{Environments: []string{}} + empty := &TrunkConfig{Environments: EnvNames()} assert.False(t, empty.IsSingleEnvironment()) } func TestIsFirstEnvironment(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "staging", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "staging", "prod")} assert.True(t, cfg.IsFirstEnvironment("dev")) assert.False(t, cfg.IsFirstEnvironment("staging")) @@ -137,7 +137,7 @@ func TestIsFirstEnvironment(t *testing.T) { } func TestIsLastEnvironment(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "staging", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "staging", "prod")} assert.False(t, cfg.IsLastEnvironment("dev")) assert.False(t, cfg.IsLastEnvironment("staging")) @@ -150,7 +150,7 @@ func TestIsLastEnvironment(t *testing.T) { } func TestGetEnvironmentIndex(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "staging", "uat", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "staging", "uat", "prod")} assert.Equal(t, 0, cfg.GetEnvironmentIndex("dev")) assert.Equal(t, 1, cfg.GetEnvironmentIndex("staging")) @@ -160,7 +160,7 @@ func TestGetEnvironmentIndex(t *testing.T) { } func TestGetNextEnvironment(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "staging", "uat", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "staging", "uat", "prod")} assert.Equal(t, "staging", cfg.GetNextEnvironment("dev")) assert.Equal(t, "uat", cfg.GetNextEnvironment("staging")) @@ -170,7 +170,7 @@ func TestGetNextEnvironment(t *testing.T) { } func TestGetEnvironmentsInRange(t *testing.T) { - cfg := &TrunkConfig{Environments: []string{"dev", "staging", "uat", "perf", "prod"}} + cfg := &TrunkConfig{Environments: EnvNames("dev", "staging", "uat", "perf", "prod")} tests := []struct { name string @@ -1062,7 +1062,7 @@ func TestComponentsRoundTrip(t *testing.T) { Config: &TrunkConfig{ SchemaVersion: 1, TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), Components: map[string]ComponentConfig{ "api": {Path: "services/api", TagGrammar: &TagGrammarConfig{Prefix: strptr("api-v")}}, }, diff --git a/internal/config/validate_environment_test.go b/internal/config/validate_environment_test.go index 18c2d896..bb5e9f53 100644 --- a/internal/config/validate_environment_test.go +++ b/internal/config/validate_environment_test.go @@ -11,149 +11,117 @@ func TestValidateEnvironmentConfigFields(t *testing.T) { tests := []struct { name string - envConfig map[string]EnvironmentConfig + envConfig EnvironmentConfig wantErr bool errContains string }{ { name: "nil environment_config is valid", - envConfig: nil, + envConfig: EnvironmentConfig{}, wantErr: false, }, { name: "empty per-env block is valid (shape only)", - envConfig: map[string]EnvironmentConfig{"prod": {}}, + envConfig: EnvironmentConfig{}, wantErr: false, }, { name: "full valid block", - envConfig: map[string]EnvironmentConfig{ - "prod": { - GHAEnvironment: "production", - RequiredReviewers: []string{"octocat", "team/ops"}, - WaitTimer: intPtr(10), - BranchPolicy: EnvBranchPolicyCustom, - BranchPatterns: []string{"main", "release/*"}, - TagPatterns: []string{"v*"}, - Secrets: []string{"MY_SECRET", "DB_PASSWORD"}, - Variables: []string{"REGION", "TIER"}, - }, + envConfig: EnvironmentConfig{ + GHAEnvironment: "production", + RequiredReviewers: []string{"octocat", "team/ops"}, + WaitTimer: intPtr(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: intPtr(0)}, - }, - wantErr: false, + name: "wait_timer zero is valid", + envConfig: EnvironmentConfig{WaitTimer: intPtr(0)}, + wantErr: false, }, { - name: "wait_timer at maximum is valid", - envConfig: map[string]EnvironmentConfig{ - "prod": {WaitTimer: intPtr(MaxWaitTimerMinutes)}, - }, - wantErr: false, + name: "wait_timer at maximum is valid", + envConfig: EnvironmentConfig{WaitTimer: intPtr(MaxWaitTimerMinutes)}, + wantErr: false, }, { - name: "wait_timer above maximum is rejected", - envConfig: map[string]EnvironmentConfig{ - "prod": {WaitTimer: intPtr(MaxWaitTimerMinutes + 1)}, - }, + name: "wait_timer above maximum is rejected", + envConfig: EnvironmentConfig{WaitTimer: intPtr(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: intPtr(-1)}, - }, + name: "negative wait_timer is rejected", + envConfig: EnvironmentConfig{WaitTimer: intPtr(-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: "protected branch policy is valid", + envConfig: EnvironmentConfig{BranchPolicy: EnvBranchPolicyProtected}, + wantErr: false, }, { - name: "all branch policy is valid", - envConfig: map[string]EnvironmentConfig{ - "prod": {BranchPolicy: EnvBranchPolicyAll}, - }, - wantErr: false, + name: "all branch policy is valid", + envConfig: EnvironmentConfig{BranchPolicy: EnvBranchPolicyAll}, + wantErr: false, }, { - name: "unknown branch policy is rejected", - envConfig: map[string]EnvironmentConfig{ - "prod": {BranchPolicy: "sometimes"}, - }, + name: "unknown branch policy is rejected", + envConfig: EnvironmentConfig{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"}}, - }, + name: "branch_patterns without custom policy is rejected", + envConfig: EnvironmentConfig{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*"}}, - }, + name: "tag_patterns without custom policy is rejected", + envConfig: EnvironmentConfig{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{""}}, - }, + name: "empty reviewer slug is rejected", + envConfig: EnvironmentConfig{RequiredReviewers: []string{""}}, wantErr: true, errContains: "required_reviewers[0]", }, { - name: "whitespace reviewer slug is rejected", - envConfig: map[string]EnvironmentConfig{ - "prod": {RequiredReviewers: []string{"team ops"}}, - }, + name: "whitespace reviewer slug is rejected", + envConfig: EnvironmentConfig{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"}}, - }, + name: "reviewer slug with too many segments is rejected", + envConfig: EnvironmentConfig{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"}}, - }, + name: "secret name starting with a digit is rejected", + envConfig: EnvironmentConfig{Secrets: []string{"1SECRET"}}, wantErr: true, errContains: "secrets[0]", }, { - name: "secret name with interpolation is rejected", - envConfig: map[string]EnvironmentConfig{ - "prod": {Secrets: []string{"${{ secrets.X }}"}}, - }, + name: "secret name with interpolation is rejected", + envConfig: EnvironmentConfig{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"}}, - }, + name: "variable name with whitespace is rejected", + envConfig: EnvironmentConfig{Variables: []string{"BAD NAME"}}, wantErr: true, errContains: "variables[0]", }, @@ -164,8 +132,9 @@ func TestValidateEnvironmentConfigFields(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg := &TrunkConfig{ - Environments: []string{"prod"}, - EnvironmentConfig: tt.envConfig, + Environments: []EnvironmentEntry{ + {Name: "prod", EnvironmentConfig: tt.envConfig}, + }, } errs := validateEnvironmentConfig(cfg) if tt.wantErr { @@ -185,18 +154,16 @@ func TestValidateEnvironmentConfigFields(t *testing.T) { } // TestParseEnvironmentConfigReservedFields asserts a manifest carrying the -// additive environment_config fields parses into the typed fields, validates at -// CurrentSchemaVersion, and does not bump schema_version. +// additive per-environment fields, folded inline onto an environments: entry, +// 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: +environments: + - dev + - name: prod gha_environment: production required_reviewers: [octocat, team/ops] wait_timer: 10 @@ -205,11 +172,21 @@ environment_config: tag_patterns: ["v*"] secrets: [MY_SECRET, DB_PASSWORD] variables: [REGION, TIER] +deploys: + - name: app + workflow: .github/workflows/deploy.yaml `) - ec, ok := cfg.EnvironmentConfig["prod"] - if !ok { - t.Fatalf("environment_config.prod did not parse") + var ec EnvironmentConfig + found := false + for _, entry := range cfg.Environments { + if entry.Name == "prod" { + ec = entry.EnvironmentConfig + found = true + } + } + if !found { + t.Fatalf("environments entry for prod did not parse") } if ec.GHAEnvironment != "production" { t.Fatalf("gha_environment: %q", ec.GHAEnvironment) diff --git a/internal/config/validate_extra_triggers_test.go b/internal/config/validate_extra_triggers_test.go index c6105065..b10780b4 100644 --- a/internal/config/validate_extra_triggers_test.go +++ b/internal/config/validate_extra_triggers_test.go @@ -12,7 +12,7 @@ import ( func extraTriggersBaseConfig() *TrunkConfig { return &TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), } } diff --git a/internal/config/validate_rollback_test.go b/internal/config/validate_rollback_test.go index a36e0c59..8a10d38f 100644 --- a/internal/config/validate_rollback_test.go +++ b/internal/config/validate_rollback_test.go @@ -12,7 +12,7 @@ import ( func rollbackBaseConfig() *TrunkConfig { return &TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: EnvNames("dev", "prod"), } } diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 5c817d2c..80edef37 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -398,18 +398,9 @@ func validateConfigLevel(cfg *TrunkConfig) []string { } } - // environment_config keys must reference declared environments. - if len(cfg.EnvironmentConfig) > 0 { - envSet := make(map[string]bool, len(cfg.Environments)) - for _, e := range cfg.Environments { - envSet[e] = true - } - for _, name := range sortedKeys(toEnvKeyed(cfg.EnvironmentConfig)) { - if !envSet[name] { - errs = append(errs, fmt.Sprintf("environment_config has key %q which is not in environments %v", name, cfg.Environments)) - } - } - } + // Folding per-environment settings onto each environments entry makes the + // former "environment_config key must be a declared environment" check + // structurally impossible to violate: settings ride on the entry itself. errs = append(errs, validateTelemetry(cfg.Telemetry)...) errs = append(errs, validateEnvironmentConfig(cfg)...) @@ -664,13 +655,13 @@ func validateSecretReference(field, value string) []string { // 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 { + if len(cfg.Environments) == 0 { return nil } var errs []string - for _, name := range sortedKeys(toEnvKeyed(cfg.EnvironmentConfig)) { - ec := cfg.EnvironmentConfig[name] - prefix := "environment_config." + name + for i, entry := range cfg.Environments { + ec := entry.EnvironmentConfig + prefix := fmt.Sprintf("environments[%d]", i) if ec.WaitTimer != nil && (*ec.WaitTimer < 0 || *ec.WaitTimer > MaxWaitTimerMinutes) { errs = append(errs, fmt.Sprintf("%s.wait_timer must be between 0 and %d minutes", prefix, MaxWaitTimerMinutes)) @@ -711,6 +702,54 @@ func validateEnvironmentConfig(cfg *TrunkConfig) []string { return errs } +// validateEnvironments enforces the structural rules the folded environments +// list of objects introduces: every entry needs a non-empty, unique name; a +// declared role must be a known value with at most one release and one +// prerelease across the list; and any unmodeled key on an environment mapping is +// a hard error with a did-you-mean suggestion (mirroring the top-level and +// per-callback strictness). +func validateEnvironments(cfg *TrunkConfig) []string { + if len(cfg.Environments) == 0 { + return nil + } + var errs []string + known := knownEnvironmentEntryFields() + seen := make(map[string]bool, len(cfg.Environments)) + roleCount := make(map[string]int, 2) + for i, entry := range cfg.Environments { + prefix := fmt.Sprintf("environments[%d]", i) + if entry.Name == "" { + errs = append(errs, fmt.Sprintf("%s.name is required", prefix)) + } else if seen[entry.Name] { + errs = append(errs, fmt.Sprintf("duplicate environment name: %s", entry.Name)) + } else { + seen[entry.Name] = true + } + switch entry.Role { + case "", EnvRolePrerelease, EnvRoleRelease: + if entry.Role != "" { + roleCount[entry.Role]++ + } + default: + errs = append(errs, fmt.Sprintf("%s.role must be one of: %s, %s", prefix, EnvRolePrerelease, EnvRoleRelease)) + } + for _, key := range sortedKeys(toAnyKeyed(entry.Extra)) { + if suggestion := fieldSuggestion(key, known, legacyFieldRenames); suggestion != "" { + errs = append(errs, fmt.Sprintf("%s has unknown field %q; did you mean %q?", prefix, key, suggestion)) + } else { + errs = append(errs, fmt.Sprintf("%s has unknown field %q", prefix, key)) + } + } + } + if roleCount[EnvRoleRelease] > 1 { + errs = append(errs, fmt.Sprintf("at most one environment may declare role: %s", EnvRoleRelease)) + } + if roleCount[EnvRolePrerelease] > 1 { + errs = append(errs, fmt.Sprintf("at most one environment may declare role: %s", EnvRolePrerelease)) + } + 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 @@ -900,15 +939,6 @@ func toStringKeyed(m map[string]DispatchInput) map[string]string { return out } -// toEnvKeyed adapts an EnvironmentConfig map to a string-keyed map for sortedKeys. -func toEnvKeyed(m map[string]EnvironmentConfig) map[string]string { - out := make(map[string]string, len(m)) - for k := range m { - out[k] = "" - } - return out -} - // toAnyKeyed adapts an inline catch-all map to a string-keyed map for sortedKeys. func toAnyKeyed(m map[string]any) map[string]string { out := make(map[string]string, len(m)) diff --git a/internal/environments/command_test.go b/internal/environments/command_test.go index 35334751..e6bb5ec1 100644 --- a/internal/environments/command_test.go +++ b/internal/environments/command_test.go @@ -11,26 +11,25 @@ import ( "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. +// minimalManifest is a hand-written manifest declaring two environments, one of +// them (production) carrying inline per-environment settings 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: + - name: production gha_environment: prod required_reviewers: [octocat, team/ops] wait_timer: 15 branch_policy: protected secrets: [MY_SECRET] variables: [REGION] + deploys: + - name: services + workflow: .github/workflows/deploy.yaml ` // writeManifest writes minimalManifest into a temp .github/manifest.yaml and diff --git a/internal/environments/payload.go b/internal/environments/payload.go index a6344b4c..b3abd76f 100644 --- a/internal/environments/payload.go +++ b/internal/environments/payload.go @@ -115,8 +115,8 @@ type OperatorTodo struct { // "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] + for _, name := range cfg.EnvironmentNames() { + ec, _ := cfg.EnvConfig(name) ghaEnv := ec.GHAEnvironment if ghaEnv == "" { diff --git a/internal/environments/payload_test.go b/internal/environments/payload_test.go index 1d021d96..befa871e 100644 --- a/internal/environments/payload_test.go +++ b/internal/environments/payload_test.go @@ -11,26 +11,32 @@ import ( ) // 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. +// per-environment 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: intPtr(10), - BranchPolicy: config.EnvBranchPolicyProtected, - Secrets: []string{"MY_SECRET", "DB_PASSWORD"}, - Variables: []string{"REGION"}, + Environments: []config.EnvironmentEntry{ + { + Name: "prod", + EnvironmentConfig: config.EnvironmentConfig{ + GHAEnvironment: "production", + RequiredReviewers: []string{"octocat", "team/ops"}, + WaitTimer: intPtr(10), + BranchPolicy: config.EnvBranchPolicyProtected, + Secrets: []string{"MY_SECRET", "DB_PASSWORD"}, + Variables: []string{"REGION"}, + }, }, - "dev": { - BranchPolicy: config.EnvBranchPolicyCustom, - BranchPatterns: []string{"main", "release/*"}, - TagPatterns: []string{"v*"}, + { + Name: "dev", + EnvironmentConfig: config.EnvironmentConfig{ + BranchPolicy: config.EnvBranchPolicyCustom, + BranchPatterns: []string{"main", "release/*"}, + TagPatterns: []string{"v*"}, + }, }, - // "test" intentionally has no environment_config entry. + // "test" intentionally carries no inline settings. + {Name: "test"}, }, } } @@ -83,7 +89,7 @@ func TestBuild_OrdersByManifestAndDefaults(t *testing.T) { func TestBuild_NoEnvironmentConfigBlock(t *testing.T) { t.Parallel() - cfg := &config.TrunkConfig{Environments: []string{"staging", "prod"}} + cfg := &config.TrunkConfig{Environments: config.EnvNames("staging", "prod")} p := Build(cfg) require.Len(t, p.Environments, 2) @@ -99,9 +105,8 @@ func TestBuild_AllBranchPolicyIsNull(t *testing.T) { t.Parallel() cfg := &config.TrunkConfig{ - Environments: []string{"prod"}, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {BranchPolicy: config.EnvBranchPolicyAll}, + Environments: []config.EnvironmentEntry{ + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{BranchPolicy: config.EnvBranchPolicyAll}}, }, } p := Build(cfg) diff --git a/internal/external/command_test.go b/internal/external/command_test.go index d89ea656..42457feb 100644 --- a/internal/external/command_test.go +++ b/internal/external/command_test.go @@ -68,7 +68,7 @@ other_key: preserved cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), }, State: map[string]*config.EnvState{ "dev": { diff --git a/internal/generate/action_pins_test.go b/internal/generate/action_pins_test.go index 988c6688..4458fec3 100644 --- a/internal/generate/action_pins_test.go +++ b/internal/generate/action_pins_test.go @@ -223,7 +223,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), PinMode: config.PinModeSHA, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, diff --git a/internal/generate/app_token_source_test.go b/internal/generate/app_token_source_test.go index 6cff39ee..5c03bb78 100644 --- a/internal/generate/app_token_source_test.go +++ b/internal/generate/app_token_source_test.go @@ -16,7 +16,7 @@ import ( // token source configured, no minting step and no minted-token fallback ref are // emitted, so generated output stays exactly as before. func TestReleaseGenerator_NoAppTokenByDefault(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("prod")} content, err := NewReleaseGenerator(cfg, "").Generate() require.NoError(t, err) @@ -33,7 +33,7 @@ func TestReleaseGenerator_NoAppTokenByDefault(t *testing.T) { func TestReleaseGenerator_ReleaseAppTokenMints(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), ReleaseTokenApp: &config.AppTokenSource{ AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY", @@ -62,7 +62,7 @@ func TestReleaseGenerator_ReleaseAppTokenMints(t *testing.T) { func TestReleaseGenerator_StateAppTokenMints(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), StateTokenApp: &config.AppTokenSource{ AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY", @@ -83,7 +83,7 @@ func TestReleaseGenerator_StateAppTokenMints(t *testing.T) { func TestMintStep_FallbackPrefersConfiguredStaticToken(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), ReleaseToken: "MY_RELEASE_PAT", ReleaseTokenApp: &config.AppTokenSource{ AppID: "CASCADE_APP_ID", @@ -114,7 +114,7 @@ func TestResolveTokenRef_OffStateIdentity(t *testing.T) { func TestReleaseGenerator_ReleaseTokenDefaultsToStateToken(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", } content, err := NewReleaseGenerator(cfg, "").Generate() @@ -129,7 +129,7 @@ func TestReleaseGenerator_ReleaseTokenDefaultsToStateToken(t *testing.T) { // compat state: with neither release_token nor state_token set, release steps // keep emitting the historical GITHUB_TOKEN default byte-for-byte. func TestReleaseGenerator_BothTokensUnsetKeepsGithubToken(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("prod")} content, err := NewReleaseGenerator(cfg, "").Generate() require.NoError(t, err) @@ -142,7 +142,7 @@ func TestReleaseGenerator_BothTokensUnsetKeepsGithubToken(t *testing.T) { func TestMintStepIndentation(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), ReleaseTokenApp: &config.AppTokenSource{ AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY", @@ -166,7 +166,7 @@ func TestAppTokenSource_Actionlint(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), ReleaseTokenApp: &config.AppTokenSource{ AppID: "CASCADE_APP_ID", PrivateKey: "CASCADE_APP_PRIVATE_KEY", diff --git a/internal/generate/auto_commit_sha_test.go b/internal/generate/auto_commit_sha_test.go index 9c0d2812..22475447 100644 --- a/internal/generate/auto_commit_sha_test.go +++ b/internal/generate/auto_commit_sha_test.go @@ -49,7 +49,7 @@ func TestAnyAutoCommits_None(t *testing.T) { // callback has auto_commits: true. func TestManifestUpdateStep_AutoCommitsReResolvesHEAD(t *testing.T) { cfg := &config.TrunkConfig{ - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", AutoCommits: true}, }, @@ -71,7 +71,7 @@ func TestManifestUpdateStep_AutoCommitsReResolvesHEAD(t *testing.T) { // declares auto_commits: true, preserving existing behavior. func TestManifestUpdateStep_NoAutoCommits_NoReResolve(t *testing.T) { cfg := &config.TrunkConfig{ - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, diff --git a/internal/generate/callback_permissions_test.go b/internal/generate/callback_permissions_test.go index c0f1281c..1c2ad9d0 100644 --- a/internal/generate/callback_permissions_test.go +++ b/internal/generate/callback_permissions_test.go @@ -60,7 +60,7 @@ func TestOrchestrate_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -95,7 +95,7 @@ func TestOrchestrate_CallbackJobPermissions_CarryDeclaredScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -149,7 +149,7 @@ func TestOrchestrate_TopLevelPermissions_NoCallbackPermsByteIdentical(t *testing cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -171,7 +171,7 @@ func TestOrchestrate_TopLevelPermissions_NoCallbackPermsByteIdentical(t *testing func TestPromote_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "services", @@ -201,7 +201,7 @@ func TestPromote_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { func TestRollback_TopLevelPermissions_ExcludesCallbackScopes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "services", diff --git a/internal/generate/cancellation_test.go b/internal/generate/cancellation_test.go index 6cd92ea4..e4f10900 100644 --- a/internal/generate/cancellation_test.go +++ b/internal/generate/cancellation_test.go @@ -49,7 +49,7 @@ func TestFinalizeRunsOnCancelledPredecessor(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -77,7 +77,7 @@ func TestFailureCheckMatchesCancelled(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -107,7 +107,7 @@ func TestOnFailureContinueEmitsContinueOnError(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -147,7 +147,7 @@ func TestDefaultCallbackOmitsContinueOnError(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", diff --git a/internal/generate/cli_validation_test.go b/internal/generate/cli_validation_test.go index 93162499..6e1a6f66 100644 --- a/internal/generate/cli_validation_test.go +++ b/internal/generate/cli_validation_test.go @@ -17,7 +17,7 @@ import ( func TestGeneratedWorkflow_PreflightCommandValid(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -48,7 +48,7 @@ func TestGeneratedWorkflow_PreflightCommandValid(t *testing.T) { func TestGeneratedWorkflow_FinalizeCommandValid(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -78,7 +78,7 @@ func TestGeneratedWorkflow_FinalizeCommandValid(t *testing.T) { func TestGeneratedWorkflow_PromoteJobNoDirectCLICall(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -113,7 +113,7 @@ func TestGeneratedWorkflow_PromoteJobNoDirectCLICall(t *testing.T) { func TestGeneratedWorkflow_ValidCLISubcommands(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -171,7 +171,7 @@ func TestGeneratedWorkflow_ValidCLISubcommands(t *testing.T) { func TestPromoteJobStepName(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -194,7 +194,7 @@ func TestPromoteJobStepName(t *testing.T) { func TestGeneratedWorkflow_NoOrphanedFlags(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -230,7 +230,7 @@ func TestGeneratedWorkflow_NoOrphanedFlags(t *testing.T) { func TestGeneratedWorkflow_PromotePreflightStructure(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -270,7 +270,7 @@ func TestGeneratedWorkflow_PromotePreflightStructure(t *testing.T) { func TestGeneratedWorkflow_PromoteFinalizeStructure(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -313,7 +313,7 @@ func TestGeneratedWorkflow_PromoteFinalizeStructure(t *testing.T) { func TestGeneratedWorkflow_PreflightNoInvalidFlags(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -354,7 +354,7 @@ func TestGeneratedWorkflow_PreflightNoInvalidFlags(t *testing.T) { func TestGeneratedWorkflow_ChangelogCommandValid(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, diff --git a/internal/generate/component_workflows_test.go b/internal/generate/component_workflows_test.go index a2210586..287ca048 100644 --- a/internal/generate/component_workflows_test.go +++ b/internal/generate/component_workflows_test.go @@ -15,7 +15,7 @@ import ( func twoComponentConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Components: map[string]config.ComponentConfig{ "api": {Path: "services/api", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("api-")}}, "web": {Path: "services/web", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("web-")}}, @@ -28,7 +28,7 @@ func twoComponentConfig() *config.TrunkConfig { // at the output path whose content is byte-identical to a directly built // generator. This is the byte-identical guarantee for existing manifests. func TestOrchestrateTargets_SingleComponent_ByteIdentical(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} targets, err := orchestrateTargets(cfg, "", ".github/workflows/orchestrate.yaml", nil, false) if err != nil { @@ -183,7 +183,7 @@ func TestOrchestrate_Components_ScopeSeedStateWrite(t *testing.T) { // no components: block keeps writing the flat state. row, byte-identical to // the historical behavior (no state.components. path leaks in). func TestOrchestrate_SingleComponent_SeedStateWriteStaysFlat(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} workflow, err := NewGenerator(cfg, "").Generate() require.NoError(t, err) step := updateManifestStep(t, workflow) @@ -198,7 +198,7 @@ func TestOrchestrate_SingleComponent_SeedStateWriteStaysFlat(t *testing.T) { // orchestrate workflow emits no --component flag, keeping its setup invocation // byte-identical to the pre-component generator. func TestGenerator_SingleComponent_NoComponentFlag(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} got, err := NewGenerator(cfg, "").Generate() if err != nil { t.Fatalf("Generate: %v", err) diff --git a/internal/generate/cross_repo_callback_test.go b/internal/generate/cross_repo_callback_test.go index f965e0f5..94fd5cbf 100644 --- a/internal/generate/cross_repo_callback_test.go +++ b/internal/generate/cross_repo_callback_test.go @@ -25,7 +25,7 @@ func TestCrossRepoBuildCallback_GeneratesWithoutLocalRead(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "prod"}, + Environments: config.EnvNames("staging", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build-app.yaml"}, {Name: "sharedlib", Workflow: crossRepoWorkflow}, @@ -67,7 +67,7 @@ func TestCrossRepoCallback_OperatorInputsPassThrough(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging"}, + Environments: config.EnvNames("staging"), Builds: []config.BuildConfig{ { Name: "sharedlib", diff --git a/internal/generate/custom_changelog_test.go b/internal/generate/custom_changelog_test.go index fca668f8..87ff4591 100644 --- a/internal/generate/custom_changelog_test.go +++ b/internal/generate/custom_changelog_test.go @@ -136,7 +136,7 @@ func TestCustomChangelog_Actionlint(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Changelog: &config.ChangelogConfig{Workflow: ".github/workflows/custom-changelog.yaml", Contributors: boolPtr(true)}, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, diff --git a/internal/generate/deploy_strategy_test.go b/internal/generate/deploy_strategy_test.go index 94b568b6..17550fa1 100644 --- a/internal/generate/deploy_strategy_test.go +++ b/internal/generate/deploy_strategy_test.go @@ -17,7 +17,7 @@ func boolPtr(b bool) *bool { return &b } func minimalCfg(deploys []config.DeployConfig) *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: deploys, } } diff --git a/internal/generate/deploytarget_reserved_test.go b/internal/generate/deploytarget_reserved_test.go index 4d6458cb..e08d245e 100644 --- a/internal/generate/deploytarget_reserved_test.go +++ b/internal/generate/deploytarget_reserved_test.go @@ -37,7 +37,7 @@ func TestDeployTargetReservedFieldsAreByteIdentical(t *testing.T) { cfgA := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -49,7 +49,7 @@ func TestDeployTargetReservedFieldsAreByteIdentical(t *testing.T) { cfgB := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", diff --git a/internal/generate/determinism_test.go b/internal/generate/determinism_test.go index 36edff50..7339e0f0 100644 --- a/internal/generate/determinism_test.go +++ b/internal/generate/determinism_test.go @@ -68,7 +68,7 @@ on: func determinismConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod", "canary"}, + Environments: config.EnvNames("dev", "staging", "prod", "canary"), Builds: []config.BuildConfig{ { Name: "image", diff --git a/internal/generate/dispatch_only_test.go b/internal/generate/dispatch_only_test.go index 550c4055..24775f66 100644 --- a/internal/generate/dispatch_only_test.go +++ b/internal/generate/dispatch_only_test.go @@ -20,7 +20,7 @@ func dispatchOnlyTestConfig(t *testing.T, dispatchOnly bool) (*config.TrunkConfi cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/generate/drift_check_test.go b/internal/generate/drift_check_test.go index 7dac818a..b2b0df79 100644 --- a/internal/generate/drift_check_test.go +++ b/internal/generate/drift_check_test.go @@ -16,7 +16,7 @@ import ( func driftCheckConfig(comment bool) *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), DriftCheck: &config.DriftCheckConfig{Enabled: true, Comment: comment}, } } diff --git a/internal/generate/dry_run_test.go b/internal/generate/dry_run_test.go index ce18a421..19f79359 100644 --- a/internal/generate/dry_run_test.go +++ b/internal/generate/dry_run_test.go @@ -17,7 +17,7 @@ import ( func TestPromote_SupportsDryRun_SingleDeploy(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -60,7 +60,7 @@ func TestPromote_SupportsDryRun_SingleDeploy(t *testing.T) { func TestPromote_NoSupportsDryRun_SingleDeploy(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -92,7 +92,7 @@ func TestPromote_NoSupportsDryRun_SingleDeploy(t *testing.T) { func TestPromote_SupportsDryRun_ProdDeploy(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -122,7 +122,7 @@ func TestPromote_SupportsDryRun_ProdDeploy(t *testing.T) { func TestPromote_SupportsDryRun_NormalRunUnaffected(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -169,7 +169,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", @@ -219,7 +219,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", @@ -263,7 +263,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", @@ -312,7 +312,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", diff --git a/internal/generate/env_gate_reusable_test.go b/internal/generate/env_gate_reusable_test.go index 2f31ecd3..8e440bce 100644 --- a/internal/generate/env_gate_reusable_test.go +++ b/internal/generate/env_gate_reusable_test.go @@ -94,13 +94,13 @@ func TestEnvGate_Orchestrate_ExternalDeploy_NoJobLevelEnvironment(t *testing.T) cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "staging"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewGenerator(cfg, tmpDir) @@ -121,13 +121,13 @@ func TestEnvGate_Orchestrate_ExternalDeploy_NoJobLevelEnvironment(t *testing.T) func TestEnvGate_Promote_ExternalSingleDeploy_NoJobLevelEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewPromoteGenerator(cfg, "") @@ -151,13 +151,13 @@ func TestEnvGate_Promote_ExternalSingleDeploy_NoJobLevelEnvironment(t *testing.T func TestEnvGate_Promote_ExternalProdDeploy_NoJobLevelEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewPromoteGenerator(cfg, "") @@ -185,13 +185,13 @@ func TestEnvGate_Warning_ExternalDeployWithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "staging"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewGenerator(cfg, tmpDir) @@ -215,13 +215,13 @@ func TestEnvGate_Warning_InlineDeployWithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "staging"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Run: "echo deploying", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewGenerator(cfg, tmpDir) @@ -249,13 +249,14 @@ func TestEnvGate_Actionlint_ExternalDeployWithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "staging"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } orchestrate, err := NewGenerator(cfg, tmpDir).Generate() diff --git a/internal/generate/env_gates_test.go b/internal/generate/env_gates_test.go index 2bd595c2..b8f7d2ff 100644 --- a/internal/generate/env_gates_test.go +++ b/internal/generate/env_gates_test.go @@ -24,13 +24,13 @@ func TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewGenerator(cfg, tmpDir) @@ -67,7 +67,7 @@ func TestEnvGates_Orchestrate_DeployJob_WithoutGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -104,13 +104,12 @@ func TestEnvGates_Orchestrate_BuildJob_NoEnvironmentKey(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging"}, + Environments: []config.EnvironmentEntry{ + {Name: "staging", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "staging-gate"}}, + }, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "staging": {GHAEnvironment: "staging-gate"}, - }, } gen := NewGenerator(cfg, tmpDir) @@ -143,13 +142,13 @@ func TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewPromoteGenerator(cfg, tmpDir) @@ -169,7 +168,7 @@ func TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment(t *testing.T) { func TestEnvGates_Promote_SingleDeployJob_WithoutGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -202,13 +201,13 @@ func TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, } gen := NewPromoteGenerator(cfg, tmpDir) @@ -236,7 +235,7 @@ func TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment(t *testing.T) { func TestEnvGates_Promote_ProdDeployJob_WithoutGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -268,14 +267,15 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + // Only the intermediate env has gha_environment; prod does not. + {Name: "staging", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "staging-gate"}}, + {Name: "prod"}, + }, Deploys: []config.DeployConfig{ {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - // Only the intermediate env has gha_environment; prod does not. - "staging": {GHAEnvironment: "staging-gate"}, - }, } gen := NewPromoteGenerator(cfg, tmpDir) @@ -303,8 +303,8 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { // gha_environment value when configured and falls back to the cascade env name. func TestEnvGates_Helper_envGHAName(t *testing.T) { cfg := &config.TrunkConfig{ - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, + Environments: []config.EnvironmentEntry{ + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, }, } @@ -317,8 +317,8 @@ func TestEnvGates_Helper_envGHAName(t *testing.T) { // correctly for configs with and without gha_environment. func TestEnvGates_Helper_anyEnvHasGHAConfig(t *testing.T) { withConfig := &config.TrunkConfig{ - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, + Environments: []config.EnvironmentEntry{ + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, }, } assert.True(t, anyEnvHasGHAConfig(withConfig), "should return true when at least one env has gha_environment") @@ -327,8 +327,8 @@ func TestEnvGates_Helper_anyEnvHasGHAConfig(t *testing.T) { assert.False(t, anyEnvHasGHAConfig(withoutConfig), "should return false when no env has gha_environment") emptyValue := &config.TrunkConfig{ - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: ""}, + Environments: []config.EnvironmentEntry{ + {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: ""}}, }, } assert.False(t, anyEnvHasGHAConfig(emptyValue), "should return false when gha_environment is empty string") diff --git a/internal/generate/env_role_test.go b/internal/generate/env_role_test.go new file mode 100644 index 00000000..b1c68bda --- /dev/null +++ b/internal/generate/env_role_test.go @@ -0,0 +1,52 @@ +package generate + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPromote_ProdDeployJob_TargetsRoleReleaseEnv proves the generated prod +// deploy job targets the RELEASE environment, which is role-aware: with +// role: release on an environment that is NOT last in the list, the job must +// deploy to that env, not the positional last one. This guards the agreement +// between the generated job and the runtime prod-deployment gate +// (ProdDeployment.Environment = ReleaseEnvironment): if the job baked the +// positional last env while the gate fired on the role env's SHA, cascade would +// deploy the release SHA to the wrong native environment. +func TestPromote_ProdDeployJob_TargetsRoleReleaseEnv(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + // prod carries role: release but is NOT the last entry (monitor is). + Environments: []config.EnvironmentEntry{ + {Name: "dev"}, + {Name: "staging", Role: config.EnvRolePrerelease}, + {Name: "prod", Role: config.EnvRoleRelease}, + {Name: "monitor"}, + }, + Deploys: []config.DeployConfig{ + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + } + + gen := NewPromoteGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + // The prod deploy job is keyed on the role-release env (prod), and its + // environment input targets prod. + roleBlock := jobBlock(t, result, "deploy-svc-prod") + require.NotEmpty(t, roleBlock, "expected the prod deploy job to target the role:release env (deploy-svc-prod)") + assert.Contains(t, roleBlock, " environment: prod", + "prod deploy must target the role:release environment via the with: input") + + // It must NOT be keyed on the positional last env (monitor); that was the + // pre-fix behavior when finalEnv was derived positionally. + assert.NotContains(t, result, " deploy-svc-monitor:", + "prod deploy job must not target the positional last env (monitor) when role:release is elsewhere") +} diff --git a/internal/generate/external_test.go b/internal/generate/external_test.go index b9811ea6..f3e969f5 100644 --- a/internal/generate/external_test.go +++ b/internal/generate/external_test.go @@ -79,7 +79,7 @@ func createMockWorkflow(t *testing.T, baseDir, workflowPath string) { func TestExternalUpdateWorkflow_Generation(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -139,7 +139,7 @@ func TestExternalUpdateWorkflow_Generation(t *testing.T) { func TestSatelliteNotifyPrimary_Generation(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Notify: &config.NotifyConfig{ Repo: "example/primary-backend", Workflow: ".github/workflows/external-update.yaml", @@ -168,7 +168,7 @@ func TestSatelliteNotifyPrimary_Generation(t *testing.T) { func TestPromoteExternalDeploys_Generation(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, }, @@ -221,7 +221,7 @@ func TestExternalDeployStateTracking(t *testing.T) { // This test covers the integration between config and preflight cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app"}, }, @@ -291,7 +291,7 @@ func TestMixedLocalAndExternalDeploys(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "api", Workflow: ".github/workflows/deploy-api.yaml"}, {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml", Triggers: []string{"terraform/**"}}, @@ -352,7 +352,7 @@ func TestSatelliteConfigNoExternal(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml", Triggers: []string{"cdk/**"}}, }, @@ -392,7 +392,7 @@ func TestPrimaryWithoutExternal(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -452,7 +452,7 @@ func TestExternalWorkflowResolution(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -481,7 +481,7 @@ func TestExternalWorkflowResolution(t *testing.T) { func TestRollbackJobConditions(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, }, @@ -549,7 +549,7 @@ func TestRollbackJobConditions(t *testing.T) { func TestExternalUpdateGenerator_NotPrimary(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -575,7 +575,7 @@ func TestExternalUpdateGenerator_NotPrimary(t *testing.T) { func TestExternalUpdateGenerator_HasConcurrencyBlock(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -614,7 +614,7 @@ func TestExternalUpdateGenerator_HasConcurrencyBlock(t *testing.T) { func TestExternalUpdateGenerator_DistinctComponentsGetDistinctGroups(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -651,7 +651,7 @@ func TestExternalUpdateGenerator_DistinctComponentsGetDistinctGroups(t *testing. func TestExternalUpdateGenerator_InputsAreNotInterpolatedIntoRun(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -707,7 +707,7 @@ func TestExternalUpdateGenerator_InputsAreNotInterpolatedIntoRun(t *testing.T) { func TestExternalUpdateGenerator_ConcurrencyOverride(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -750,7 +750,7 @@ func TestExternalUpdateGenerator_CheckoutUsesStateToken(t *testing.T) { t.Run("state_token configured", func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), External: externalRepos, StateToken: "CASCADE_STATE_TOKEN", } @@ -773,7 +773,7 @@ func TestExternalUpdateGenerator_CheckoutUsesStateToken(t *testing.T) { t.Run("state_token unset uses GITHUB_TOKEN back-compat", func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), External: externalRepos, } gen := NewExternalUpdateGenerator(cfg, "/tmp") @@ -798,7 +798,7 @@ func TestExternalUpdateGenerator_CheckoutUsesStateToken(t *testing.T) { func TestExternalUpdateGenerator_DefaultConcurrencyIsPerComponent(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -836,7 +836,7 @@ func TestNotifyPrimaryStep_BuildOnlySatellite_EmitsDeployName(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "shared", Workflow: ".github/workflows/build-shared.yaml", Triggers: []string{"src/**"}}, }, @@ -874,7 +874,7 @@ func TestNotifyPrimaryStep_OverridesEmitParentNames(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "shared", Workflow: ".github/workflows/build-shared.yaml", Triggers: []string{"src/**"}}, }, @@ -908,7 +908,7 @@ func TestNotifyPrimaryStep_OverridesOffStateByteIdentical(t *testing.T) { newCfg := func() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "shared", Workflow: ".github/workflows/build-shared.yaml", Triggers: []string{"src/**"}}, }, @@ -943,7 +943,7 @@ func TestNotifyPrimaryStep_NoStrayDotInJobsAccessor(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml", Triggers: []string{"cdk/**"}}, }, @@ -967,7 +967,7 @@ func TestNotifyPrimaryStep_NoStrayDotInJobsAccessor(t *testing.T) { func onUpdatePrimaryConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -1034,7 +1034,7 @@ func TestExternalUpdateGenerator_NoOnUpdate_RecordOnlyByteIdentical(t *testing.T // stripped. The generated bytes must match a hand-built record-only baseline. recordOnly := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -1086,7 +1086,7 @@ func TestExternalUpdateGenerator_OnUpdateDeploy_Deterministic(t *testing.T) { func TestExternalUpdateGenerator_OnUpdateDeploy_LocalWorkflowPath(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", @@ -1118,7 +1118,7 @@ func TestExternalUpdateGenerator_OnUpdateDeploy_LocalWorkflowPath(t *testing.T) func TestExternalUpdateGenerator_EmitsRunName(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), External: []config.ExternalRepoConfig{ { Repo: "example/cdk-infra", diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 1e764f11..7ec92318 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -83,7 +83,7 @@ func workflowDispatchTarget(path string) string { // GHAEnvironment field is non-empty, that value is returned; otherwise the // cascade env name itself is used as the GitHub Environment name. func envGHAName(cfg *config.TrunkConfig, cascadeEnvName string) string { - if ec, ok := cfg.EnvironmentConfig[cascadeEnvName]; ok && ec.GHAEnvironment != "" { + if ec, ok := cfg.EnvConfig(cascadeEnvName); ok && ec.GHAEnvironment != "" { return ec.GHAEnvironment } return cascadeEnvName @@ -92,8 +92,8 @@ func envGHAName(cfg *config.TrunkConfig, cascadeEnvName string) string { // anyEnvHasGHAConfig reports whether any environment in the config has an // EnvironmentConfig entry with a non-empty GHAEnvironment field. func anyEnvHasGHAConfig(cfg *config.TrunkConfig) bool { - for _, ec := range cfg.EnvironmentConfig { - if ec.GHAEnvironment != "" { + for _, entry := range cfg.Environments { + if entry.GHAEnvironment != "" { return true } } @@ -713,10 +713,10 @@ func (g *Generator) writeWorkflowTriggers(sb *strings.Builder) { sb.WriteString(" description: 'Target environment'\n") sb.WriteString(" type: choice\n") sb.WriteString(" options:\n") - for _, env := range g.config.Environments { + for _, env := range g.config.EnvironmentNames() { fmt.Fprintf(sb, " - %s\n", env) } - fmt.Fprintf(sb, " default: '%s'\n", g.config.Environments[0]) + fmt.Fprintf(sb, " default: '%s'\n", g.config.Environments[0].Name) } sb.WriteString(" dry_run:\n") sb.WriteString(" description: 'Dry run mode'\n") @@ -929,7 +929,7 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) { // For no-environment setup, don't pass environment at all if len(g.config.Environments) > 0 { sb.WriteString(" env:\n") - fmt.Fprintf(sb, " ENVIRONMENT: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0]) + fmt.Fprintf(sb, " ENVIRONMENT: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0].Name) sb.WriteString(" run: |\n") sb.WriteString(" cascade orchestrate setup \\\n") sb.WriteString(" --environment \"$ENVIRONMENT\" \\\n") @@ -1202,7 +1202,7 @@ func (g *Generator) writeWithInputs(sb *strings.Builder, info CallbackInfo) { // Only pass environment if there are environments configured if len(g.config.Environments) > 0 { - inputs = append(inputs, fmt.Sprintf(" environment: ${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0])) + inputs = append(inputs, fmt.Sprintf(" environment: ${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0].Name)) } // Optional standard inputs - only passed if callback declares them @@ -1526,7 +1526,7 @@ func (g *Generator) writeNativeDeploymentSteps(sb *strings.Builder, sorted []str envExpr := "${{ github.event.inputs.environment }}" if len(g.config.Environments) > 0 { - envExpr = fmt.Sprintf("${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0]) + envExpr = fmt.Sprintf("${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0].Name) } // Collect deploy job IDs so the terminal status reflects the real deploy @@ -1625,7 +1625,7 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string // Only include environment if there are environments configured if len(g.config.Environments) > 0 { - fmt.Fprintf(sb, " ENVIRONMENT: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0]) + fmt.Fprintf(sb, " ENVIRONMENT: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0].Name) } // Add env vars for each deploy result @@ -1775,7 +1775,7 @@ func (g *Generator) writeNotifyPrimaryStep(sb *strings.Builder) { sb.WriteString(" - name: Notify Primary Repo\n") // Only notify if we're deploying to the first environment (dev) if len(g.config.Environments) > 0 { - fmt.Fprintf(sb, " if: github.event.inputs.environment == '%s' || github.event.inputs.environment == ''\n", g.config.Environments[0]) + fmt.Fprintf(sb, " if: github.event.inputs.environment == '%s' || github.event.inputs.environment == ''\n", g.config.Environments[0].Name) } writeActionUses(sb, g.config, " ", actionGithubScript) sb.WriteString(" with:\n") @@ -1829,7 +1829,7 @@ func (g *Generator) writeNotifyPrimaryStep(sb *strings.Builder) { case g.config.Notify.Environment != "": fmt.Fprintf(sb, " environment: '%s',\n", g.config.Notify.Environment) case len(g.config.Environments) > 0: - fmt.Fprintf(sb, " environment: context.payload.inputs?.environment || '%s',\n", g.config.Environments[0]) + fmt.Fprintf(sb, " environment: context.payload.inputs?.environment || '%s',\n", g.config.Environments[0].Name) default: sb.WriteString(" environment: 'dev',\n") } @@ -2044,7 +2044,7 @@ func (g *Generator) writeReleaseStep(sb *strings.Builder) { // Only include environment if there are environments configured if len(g.config.Environments) > 0 { - fmt.Fprintf(sb, " environment: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0]) + fmt.Fprintf(sb, " environment: ${{ github.event.inputs.environment || '%s' }}\n", g.config.Environments[0].Name) } else { // For no-environment repos, use a placeholder for release tracking sb.WriteString(" environment: prerelease\n") diff --git a/internal/generate/generator_normalize_test.go b/internal/generate/generator_normalize_test.go index 1bda2647..46cbef13 100644 --- a/internal/generate/generator_normalize_test.go +++ b/internal/generate/generator_normalize_test.go @@ -130,7 +130,7 @@ func TestNormalizeWorkflowPath_ActionlintClean(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "production"}, + Environments: config.EnvNames("staging", "production"), Builds: []config.BuildConfig{ {Name: "app", Workflow: "build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/generate/generator_test.go b/internal/generate/generator_test.go index 52b841ed..88a71d38 100644 --- a/internal/generate/generator_test.go +++ b/internal/generate/generator_test.go @@ -25,7 +25,7 @@ func TestGenerator_OrchestrateHasConcurrencyBlock(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -63,7 +63,7 @@ func TestGenerator_ReleaseTokenBareSecretNameIsWrapped(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "staging", "prod"}, + Environments: config.EnvNames("dev", "test", "staging", "prod"), ReleaseToken: "CASCADE_STATE_TOKEN", // bare secret name, as in the primary example Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -98,7 +98,7 @@ func TestGenerator_DefaultReleaseTokenIsGitHubToken(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -124,7 +124,7 @@ func TestGenerator_OrchestrateConcurrencyOverride(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -179,7 +179,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ { Name: "app", @@ -241,7 +241,7 @@ func TestGenerator_CallbackTimeoutMinutes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Validate: &config.ValidateConfig{ Workflow: ".github/workflows/validate.yaml", TimeoutMinutes: 5, @@ -284,7 +284,7 @@ func TestGenerator_CallbackTimeoutOmittedWhenZero(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -317,7 +317,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -370,7 +370,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -428,7 +428,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -477,7 +477,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -524,7 +524,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -583,7 +583,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -673,7 +673,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -724,7 +724,7 @@ on: require.NoError(t, err) cfg := &config.TrunkConfig{ - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -744,7 +744,7 @@ on: func TestGenerator_FinalizeJob_ReleaseDisabled(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Release: &config.ReleaseConfig{Disabled: boolPtr(true)}, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -779,7 +779,7 @@ on: func TestGenerator_FinalizeJob_ExternalRelease(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Release: &config.ReleaseConfig{Tag: "goreleaser.tag"}, Builds: []config.BuildConfig{ {Name: "goreleaser", Workflow: ".github/workflows/goreleaser.yaml", Triggers: []string{"cmd/**"}}, @@ -815,7 +815,7 @@ on: func TestGenerator_FinalizeJob_CustomChangelog(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Changelog: &config.ChangelogConfig{Workflow: ".github/workflows/custom-changelog.yaml"}, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -885,7 +885,7 @@ on: func TestGenerator_FinalizeJob_FrameworkManagedRelease(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Release: &config.ReleaseConfig{}, // No tag specified = framework-managed Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -935,7 +935,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -984,7 +984,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "shared-lib", Workflow: ".github/workflows/build.yaml", Triggers: []string{"libs/**"}}, }, @@ -1036,7 +1036,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1071,7 +1071,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{".aws/cdk/**"}}, {Name: "k8s", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{".k8s/**"}}, @@ -1121,7 +1121,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1167,7 +1167,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1205,7 +1205,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1238,7 +1238,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{".aws/cdk/**"}}, }, @@ -1280,7 +1280,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "notify", Workflow: ".github/workflows/deploy.yaml"}, // No triggers or depends_on }, @@ -1311,7 +1311,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "my-app-deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -1339,7 +1339,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1374,7 +1374,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -1402,7 +1402,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "infra", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"terraform/**", "cdk/**", "modules/**"}}, }, @@ -1433,7 +1433,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ {Name: "only-one", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -1474,7 +1474,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1505,7 +1505,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1546,7 +1546,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1578,7 +1578,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1800,7 +1800,7 @@ func TestGenerator_BuildMatrix_TwoDimensions(t *testing.T) { ptrFalse := false cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -1840,7 +1840,7 @@ func TestGenerator_BuildMatrix_MaxParallelOmittedWhenZero(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -1872,7 +1872,7 @@ func TestGenerator_BuildMatrix_NoMatrixNoStrategy(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1895,7 +1895,7 @@ func TestGenerator_PassthroughArtifact_NoArtifactNoSteps(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1927,7 +1927,7 @@ func TestGenerator_PassthroughArtifact_ReusableWorkflowUploadJob(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "compile", @@ -1968,7 +1968,7 @@ func TestGenerator_PassthroughArtifact_ReusableWorkflowDownloadJob(t *testing.T) cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "compile", @@ -2051,7 +2051,7 @@ func TestGenerator_PassthroughArtifact_DownloadNeedsUploadJob(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging"}, + Environments: config.EnvNames("staging"), Builds: []config.BuildConfig{ { Name: "image", @@ -2130,7 +2130,7 @@ func TestGenerator_PassthroughArtifact_MatrixUploadCollectsLegs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging"}, + Environments: config.EnvNames("staging"), Builds: []config.BuildConfig{ { Name: "image", @@ -2205,7 +2205,7 @@ func TestGenerator_PassthroughArtifact_NonMatrixUploadNoCollect(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "compile", @@ -2238,7 +2238,7 @@ func TestGenerator_DispatchInputs_StringType(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2271,7 +2271,7 @@ func TestGenerator_DispatchInputs_BooleanType(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2301,7 +2301,7 @@ func TestGenerator_DispatchInputs_ChoiceType(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2335,7 +2335,7 @@ func TestGenerator_DispatchInputs_RequiredFlag(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2364,7 +2364,7 @@ func TestGenerator_DispatchInputs_OmittedWhenEmpty(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2398,7 +2398,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2432,7 +2432,7 @@ func TestGenerator_DispatchInputs_NotRoutedWhenCallbackOmitsIt(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -2463,7 +2463,7 @@ func TestGenerator_DispatchInputs_SortedDeterministic(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index ab481100..10f9c285 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -169,7 +169,7 @@ func (g *HotfixGenerator) Enabled() bool { // environment except the first, which is the build target. Callers must gate on // Enabled() so the slice is non-empty. func (g *HotfixGenerator) targetEnvs() []string { - return g.config.Environments[1:] + return g.config.EnvironmentNames()[1:] } // getCLIRef mirrors the ref-resolution used by the other generators so the diff --git a/internal/generate/hotfix_rollback_fanout_test.go b/internal/generate/hotfix_rollback_fanout_test.go index 599a3a8c..899902cb 100644 --- a/internal/generate/hotfix_rollback_fanout_test.go +++ b/internal/generate/hotfix_rollback_fanout_test.go @@ -13,7 +13,7 @@ import ( func lifecycleMultiComponentConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Components: map[string]config.ComponentConfig{ "api": {Path: "services/api", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("api-")}}, "web": {Path: "services/web", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("web-")}}, @@ -26,7 +26,7 @@ func lifecycleMultiComponentConfig() *config.TrunkConfig { // at the output path whose content is byte-identical to a directly built hotfix // generator, with no component-namespaced name and no --component flag. func TestHotfixTargets_SingleComponent_ByteIdentical(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} targets, err := hotfixTargets(cfg, "") if err != nil { @@ -146,7 +146,7 @@ func TestHotfixTargets_Components_FanOut(t *testing.T) { // components: block takes the untouched single-generator path, byte-identical to // a directly built rollback generator, with no namespaced name and no --component. func TestRollbackTargets_SingleComponent_ByteIdentical(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} targets, err := rollbackTargets(cfg, "") if err != nil { diff --git a/internal/generate/hotfix_test.go b/internal/generate/hotfix_test.go index 9a42b45e..b70f8c14 100644 --- a/internal/generate/hotfix_test.go +++ b/internal/generate/hotfix_test.go @@ -22,7 +22,7 @@ import ( func threeEnvHotfixConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml"}, }, @@ -36,7 +36,7 @@ func TestHotfixGenerator_Enabled(t *testing.T) { // Two or more environments enables the hotfix workflow. assert.True(t, NewHotfixGenerator(&config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), }, "").Enabled(), "2 envs should enable the hotfix workflow") assert.True(t, NewHotfixGenerator(threeEnvHotfixConfig(), "").Enabled(), "3 envs should enable") @@ -44,7 +44,7 @@ func TestHotfixGenerator_Enabled(t *testing.T) { // Below two environments emits nothing. assert.False(t, NewHotfixGenerator(&config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), }, "").Enabled(), "1 env should not enable") assert.False(t, NewHotfixGenerator(&config.TrunkConfig{ @@ -58,7 +58,7 @@ func TestHotfixGenerator_Enabled(t *testing.T) { // TestHotfixGenerator_Threshold_EmitsNothingBelowTwoEnvs confirms the Q1 // generation threshold: with a single env the generator gate is closed. func TestHotfixGenerator_Threshold_EmitsNothingBelowTwoEnvs(t *testing.T) { - oneEnv := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev"}} + oneEnv := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev")} assert.False(t, NewHotfixGenerator(oneEnv, "").Enabled()) zeroEnv := &config.TrunkConfig{TrunkBranch: "main"} diff --git a/internal/generate/input_passthrough_test.go b/internal/generate/input_passthrough_test.go index 63886c7c..e7143259 100644 --- a/internal/generate/input_passthrough_test.go +++ b/internal/generate/input_passthrough_test.go @@ -35,7 +35,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -80,7 +80,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -111,7 +111,7 @@ on: func TestPromoteVarsExpressionPassthrough(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -146,7 +146,7 @@ func TestPromoteVarsExpressionPassthrough(t *testing.T) { func TestPromoteStateReferenceResolves(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -177,7 +177,7 @@ func TestPromoteStateReferenceResolves(t *testing.T) { func TestPromoteLiteralInputsUnchanged(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -210,7 +210,7 @@ func TestValidateWarnsUnwrappedExpression(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", diff --git a/internal/generate/job_attributes_test.go b/internal/generate/job_attributes_test.go index c3195e01..67c368be 100644 --- a/internal/generate/job_attributes_test.go +++ b/internal/generate/job_attributes_test.go @@ -25,7 +25,7 @@ func TestReusableWorkflowCallbackRendersPermissionsNotForbiddenAttributes(t *tes cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -62,7 +62,7 @@ func TestReusableWorkflowCallbackRendersOIDCPermissions(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", diff --git a/internal/generate/job_control_test.go b/internal/generate/job_control_test.go index 85993759..3613f396 100644 --- a/internal/generate/job_control_test.go +++ b/internal/generate/job_control_test.go @@ -52,7 +52,7 @@ func TestGenerator_OwnedJobsGetDefaultTimeout(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -74,7 +74,7 @@ func TestGenerator_OwnedJobTimeoutConfigurable(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), JobTimeoutMinutes: 12, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -97,7 +97,7 @@ func TestGenerator_TimeoutNotOnReusableCallback(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ // Reusable-workflow callback (uses:): no timeout-minutes. {Name: "reusable", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -124,7 +124,7 @@ func TestGenerator_OptionalDependsOnAddsNeedsWithoutGating(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "migrations", Workflow: ".github/workflows/build.yaml", Triggers: []string{"db/**"}}, {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -171,7 +171,7 @@ func TestGenerator_OptionalDependsOnHonorsImplicitRunOnSkip(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "build", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -207,7 +207,7 @@ func TestGenerator_BothFieldsUnsetNonBreaking(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -234,7 +234,7 @@ func TestGenerator_ExplicitTimeoutNotOnReusableCallback(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ // Reusable-workflow callback with an explicit timeout: must NOT emit it. {Name: "reusable", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}, TimeoutMinutes: 15}, diff --git a/internal/generate/least_privilege_permissions_test.go b/internal/generate/least_privilege_permissions_test.go index 7cac6e0f..c73b8ef0 100644 --- a/internal/generate/least_privilege_permissions_test.go +++ b/internal/generate/least_privilege_permissions_test.go @@ -47,7 +47,7 @@ func TestOrchestrate_TopLevelLeastPrivilege(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -93,7 +93,7 @@ func TestOrchestrate_NativeDeployments_JobLevelDeploymentsWrite(t *testing.T) { func TestPromote_TopLevelLeastPrivilege(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -122,14 +122,13 @@ func TestPromote_NativeDeployments_JobLevelDeploymentsWrite(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"production"}, + Environments: []config.EnvironmentEntry{ + {Name: "production", EnvironmentConfig: config.EnvironmentConfig{EnvironmentURL: "https://app.example.com"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, Deployments: &config.DeploymentsConfig{Enabled: boolPtr(true)}, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "production": {EnvironmentURL: "https://app.example.com"}, - }, } out, err := NewPromoteGenerator(cfg, tmpDir).Generate() @@ -149,7 +148,7 @@ func TestPromote_NativeDeployments_JobLevelDeploymentsWrite(t *testing.T) { func TestHotfix_TopLevelLeastPrivilege(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), } out, err := NewHotfixGenerator(cfg, "").Generate() @@ -178,7 +177,7 @@ func TestHotfix_TopLevelLeastPrivilege(t *testing.T) { func TestRollback_TopLevelLeastPrivilege(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, }, diff --git a/internal/generate/matrix_retry_test.go b/internal/generate/matrix_retry_test.go index 76abebb4..a7c1a3fb 100644 --- a/internal/generate/matrix_retry_test.go +++ b/internal/generate/matrix_retry_test.go @@ -61,7 +61,7 @@ func TestMatrixRetry_RetryJobCarriesStrategy(t *testing.T) { ptrFalse := false cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -120,7 +120,7 @@ func TestMatrixRetry_MatrixWithoutRetries(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -157,7 +157,7 @@ func TestMatrixRetry_RetriesWithoutMatrix(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", diff --git a/internal/generate/merge_queue.go b/internal/generate/merge_queue.go index be7bcf4f..0a2f1384 100644 --- a/internal/generate/merge_queue.go +++ b/internal/generate/merge_queue.go @@ -153,7 +153,7 @@ func (g *MergeQueueGenerator) writeJob(sb *strings.Builder) { sb.WriteString(" cascade --dry-run orchestrate setup \\\n") if len(g.config.Environments) > 0 { sb.WriteString(" --config \"$MANIFEST_FILE\" \\\n") - fmt.Fprintf(sb, " --environment %s\n", g.config.Environments[0]) + fmt.Fprintf(sb, " --environment %s\n", g.config.Environments[0].Name) } else { sb.WriteString(" --config \"$MANIFEST_FILE\"\n") } diff --git a/internal/generate/merge_queue_test.go b/internal/generate/merge_queue_test.go index 20367ae9..726d5bfd 100644 --- a/internal/generate/merge_queue_test.go +++ b/internal/generate/merge_queue_test.go @@ -84,7 +84,7 @@ func TestMergeQueueGenerator_DryRunResolvesEnvironment(t *testing.T) { t.Run("multi-env passes first environment", func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"staging", "prod"}, + Environments: config.EnvNames("staging", "prod"), MergeQueue: &config.MergeQueueConfig{Enabled: true}, } gen := NewMergeQueueGenerator(cfg, "") diff --git a/internal/generate/native_deployments.go b/internal/generate/native_deployments.go index 50f895c4..44bfdf05 100644 --- a/internal/generate/native_deployments.go +++ b/internal/generate/native_deployments.go @@ -92,9 +92,9 @@ func writeNativeDeploymentSteps(sb *strings.Builder, cfg *config.TrunkConfig, en // empty default. Entries are emitted in sorted order for byte-stable output. func writeEnvironmentURLCase(sb *strings.Builder, cfg *config.TrunkConfig, body string) { urls := make(map[string]string) - for name, ec := range cfg.EnvironmentConfig { - if ec.EnvironmentURL != "" { - urls[name] = ec.EnvironmentURL + for _, entry := range cfg.Environments { + if entry.EnvironmentURL != "" { + urls[entry.Name] = entry.EnvironmentURL } } sb.WriteString(body + " environment_url=\"\"\n") diff --git a/internal/generate/native_deployments_test.go b/internal/generate/native_deployments_test.go index c28efb72..52f75cd2 100644 --- a/internal/generate/native_deployments_test.go +++ b/internal/generate/native_deployments_test.go @@ -33,14 +33,13 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"production"}, + Environments: []config.EnvironmentEntry{ + {Name: "production", EnvironmentConfig: config.EnvironmentConfig{EnvironmentURL: "https://app.example.com"}}, + }, Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, Deployments: &config.DeploymentsConfig{Enabled: boolPtr(true)}, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "production": {EnvironmentURL: "https://app.example.com"}, - }, } return cfg, tmpDir } diff --git a/internal/generate/negative_paths_test.go b/internal/generate/negative_paths_test.go index ef49aac3..76f3d811 100644 --- a/internal/generate/negative_paths_test.go +++ b/internal/generate/negative_paths_test.go @@ -18,7 +18,7 @@ import ( func newNegPathsConfig(triggers []string) *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Triggers: triggers, Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml"}, diff --git a/internal/generate/pr_preview.go b/internal/generate/pr_preview.go index f475cf4d..86dfd9f7 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -180,7 +180,7 @@ func (g *PRPreviewGenerator) writeDeployDryRunStep(sb *strings.Builder) { if len(g.config.Environments) > 0 { // Target the first environment so version calculation resolves; the // preview is read-only regardless of which environment it reports on. - fmt.Fprintf(sb, " --environment %s \\\n", g.config.Environments[0]) + fmt.Fprintf(sb, " --environment %s \\\n", g.config.Environments[0].Name) } sb.WriteString(" --sha \"$HEAD_SHA\" \\\n") sb.WriteString(" > cascade-plan.json\n") diff --git a/internal/generate/pr_preview_test.go b/internal/generate/pr_preview_test.go index ad969bbf..675eecf8 100644 --- a/internal/generate/pr_preview_test.go +++ b/internal/generate/pr_preview_test.go @@ -15,7 +15,7 @@ import ( func prPreviewConfig(comment bool) *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), PRPreview: &config.PRPreviewConfig{ Enabled: boolPtr(true), Comment: boolPtr(comment), @@ -61,7 +61,7 @@ func TestPRPreviewGenerator_PRFieldsAreNotInterpolatedIntoRun(t *testing.T) { func TestPRPreviewGenerator_DryRunResolvesEnvironment(t *testing.T) { t.Run("multi-env passes first environment", func(t *testing.T) { cfg := prPreviewConfig(false) - cfg.Environments = []string{"staging", "prod"} + cfg.Environments = config.EnvNames("staging", "prod") gen := NewPRPreviewGenerator(cfg, "") content, err := gen.Generate() diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 513a4b35..34a9cdf6 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -442,7 +442,7 @@ func (g *PromoteGenerator) matrixEnvInputs(deploy *config.DeployConfig) map[stri for env := range deploy.EnvInputs { envNames[env] = struct{}{} } - for _, env := range g.config.Environments { + for _, env := range g.config.EnvironmentNames() { envNames[env] = struct{}{} } for env := range envNames { @@ -536,7 +536,7 @@ func (g *PromoteGenerator) writeHeader(sb *strings.Builder) { // Document environments sb.WriteString("# Environments: ") - for i, env := range g.config.Environments { + for i, env := range g.config.EnvironmentNames() { if i > 0 { sb.WriteString(" → ") } @@ -552,10 +552,10 @@ func (g *PromoteGenerator) writeHeader(sb *strings.Builder) { sb.WriteString("# All intermediate environments updated with same artifact\n") sb.WriteString("# Fails entirely if any step fails (no partial state)\n") sb.WriteString("#\n") - sb.WriteString("# Release states (based on position):\n") + sb.WriteString("# Release states (role, or position when no role is set):\n") if len(g.config.Environments) >= 2 { - fmt.Fprintf(sb, "# %s (second-from-top) = prerelease\n", g.config.Environments[len(g.config.Environments)-2]) - fmt.Fprintf(sb, "# %s (top) = released\n", g.config.Environments[len(g.config.Environments)-1]) + fmt.Fprintf(sb, "# %s = prerelease\n", g.config.PrereleaseEnvironment()) + fmt.Fprintf(sb, "# %s = released\n", g.config.ReleaseEnvironment()) } sb.WriteString("#\n") @@ -813,7 +813,11 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { return } - finalEnv := g.config.Environments[len(g.config.Environments)-1] + // The prod deploy job targets the release environment, which is role-aware: + // role: release on an entry moves it off the positional last, and the runtime + // prod-deployment gate (ProdDeployment.Environment = ReleaseEnvironment) reads + // the same env, so the generated job and the runtime SHA must agree. + finalEnv := g.config.ReleaseEnvironment() // Write local deploy jobs for _, d := range g.config.Deploys { diff --git a/internal/generate/promote_fanout_test.go b/internal/generate/promote_fanout_test.go index 35ac7648..becdfd39 100644 --- a/internal/generate/promote_fanout_test.go +++ b/internal/generate/promote_fanout_test.go @@ -15,7 +15,7 @@ func strptr(s string) *string { return &s } func promoteMultiComponentConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Components: map[string]config.ComponentConfig{ "api": {Path: "services/api", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("api-")}}, "web": {Path: "services/web", TagGrammar: &config.TagGrammarConfig{Prefix: strptr("web-")}}, @@ -28,7 +28,7 @@ func promoteMultiComponentConfig() *config.TrunkConfig { // at the output path whose content is byte-identical to a directly built promote // generator, with no component-namespaced name and no --component flag. func TestPromoteTargets_SingleComponent_ByteIdentical(t *testing.T) { - cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")} targets, err := promoteTargets(cfg, "", ".github/workflows/promote.yaml", nil) if err != nil { diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index 7a981b8f..4b44b146 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -87,7 +87,7 @@ func stepRunBody(t *testing.T, content, stepName string) string { func TestPromoteGenerator_ModeInputNotInterpolatedIntoRun(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -138,7 +138,7 @@ func TestPromoteGenerator_Generate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: tt.environments, + Environments: config.EnvNames(tt.environments...), } gen := NewPromoteGenerator(cfg, "") @@ -180,7 +180,7 @@ func TestPromoteGenerator_Generate(t *testing.T) { func TestPromoteGenerator_EnvironmentCases(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "uat", "prod"}, + Environments: config.EnvNames("dev", "staging", "uat", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -208,7 +208,7 @@ func TestPromoteGenerator_EnvironmentCases(t *testing.T) { func TestPromoteGenerator_ValidYAML(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -235,7 +235,7 @@ func TestPromoteGenerator_ValidYAML(t *testing.T) { func TestPromoteGenerator_OrphanCleanup(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "uat", "perf", "prod"}, + Environments: config.EnvNames("dev", "staging", "uat", "perf", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -251,7 +251,7 @@ func TestPromoteGenerator_OrphanCleanup(t *testing.T) { func TestPromoteGenerator_PublishOnProd(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -283,7 +283,7 @@ func TestPromoteGenerator_PublishOnProd(t *testing.T) { func TestPromoteGenerator_PreflightDeclaresSourceImageTag(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -325,7 +325,7 @@ jobs: func TestPromoteGenerator_PreflightDeclaresSourceImageDigest(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -349,7 +349,7 @@ func TestPromoteGenerator_DeployThreadsImageDigestWhenDeclared(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -401,7 +401,7 @@ jobs: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -424,7 +424,7 @@ jobs: func TestPromoteGenerator_DryRunSupport(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -442,7 +442,7 @@ func TestPromoteGenerator_DryRunSupport(t *testing.T) { func TestPromoteGenerator_DeployCheckboxes(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app-deploy", Workflow: ".github/workflows/deploy.yaml", DependsOn: []string{"app"}}, {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml", Triggers: []string{".aws/cdk/**"}}, @@ -504,7 +504,7 @@ func TestPromoteGenerator_DeployCheckboxes(t *testing.T) { func TestPromoteGenerator_DeployDetectionOutputs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml", Triggers: []string{".aws/cdk/**"}}, }, @@ -528,7 +528,7 @@ func TestPromoteGenerator_DeployDetectionOutputs(t *testing.T) { func TestPromoteGenerator_ConditionalDeployJobs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml", Triggers: []string{".aws/cdk/**"}}, {Name: "k8s", Workflow: ".github/workflows/deploy-k8s.yaml", Triggers: []string{".k8s/**"}}, @@ -555,7 +555,7 @@ func TestPromoteGenerator_ConditionalDeployJobs(t *testing.T) { func TestPromoteGenerator_FinalizeNeedsAllDeploys(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml"}, {Name: "k8s", Workflow: ".github/workflows/deploy-k8s.yaml"}, @@ -573,7 +573,7 @@ func TestPromoteGenerator_FinalizeNeedsAllDeploys(t *testing.T) { func TestPromoteGenerator_PerDeployManifestUpdate(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml"}, }, @@ -598,7 +598,7 @@ func TestPromoteGenerator_MixedDeployTypes(t *testing.T) { // Test with all three deploy types: build-linked, trigger-based, and unconstrained cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -634,7 +634,7 @@ func TestPromoteGenerator_MixedDeployTypes(t *testing.T) { func TestPromoteGenerator_DeployCheckboxEnvVarPassing(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "my-deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, {Name: "other-deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"lib/**"}}, @@ -661,7 +661,7 @@ func TestPromoteGenerator_EmptyDeploys(t *testing.T) { // Config with no deploys should still generate valid workflow cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -689,7 +689,7 @@ func TestPromoteGenerator_EmptyDeploys(t *testing.T) { func TestPromoteGenerator_SingleDeploy(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "only-deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -712,7 +712,7 @@ func TestPromoteGenerator_SingleDeploy(t *testing.T) { func TestPromoteGenerator_DeployNameWithSpecialChars(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "my-app-deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, {Name: "cdk_infra", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"cdk/**"}}, @@ -735,7 +735,7 @@ func TestPromoteGenerator_DeployNameWithSpecialChars(t *testing.T) { func TestPromoteGenerator_DeployJobPassesEnvironmentAndSHA(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml", Triggers: []string{"src/**"}}, }, @@ -753,7 +753,7 @@ func TestPromoteGenerator_DeployJobPassesEnvironmentAndSHA(t *testing.T) { func TestPromoteGenerator_DeployResultEnvVars(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "cdk", Workflow: ".github/workflows/deploy-cdk.yaml"}, {Name: "k8s", Workflow: ".github/workflows/deploy-k8s.yaml"}, @@ -775,7 +775,7 @@ func TestPromoteGenerator_DeployResultEnvVars(t *testing.T) { func TestPromoteGenerator_DeployManifestUpdateTimestamp(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -795,7 +795,7 @@ func TestPromoteGenerator_DeployManifestUpdateTimestamp(t *testing.T) { func TestPromoteGenerator_PreflightOutputsDeploysToRun(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -816,7 +816,7 @@ func TestPromoteGenerator_PreflightOutputsDeploysToRun(t *testing.T) { func TestPromoteGenerator_BuildLinkedDeployInheritsTriggersForDetection(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "api", Workflow: ".github/workflows/build.yaml", Triggers: []string{"api/src/**", "api/go.mod"}}, }, @@ -840,7 +840,7 @@ func TestPromoteGenerator_BuildLinkedDeployInheritsTriggersForDetection(t *testi func TestPromoteGenerator_TriggerBasedDeployUsesOwnTriggers(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"terraform/**", "cdk/**"}}, }, @@ -861,7 +861,7 @@ func TestPromoteGenerator_TriggerBasedDeployUsesOwnTriggers(t *testing.T) { func TestPromoteGenerator_UnconstrainedDeployAlwaysInList(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "notify", Workflow: ".github/workflows/notify.yaml"}, // No triggers or depends_on }, @@ -883,7 +883,7 @@ func TestPromoteGenerator_UnconstrainedDeployAlwaysInList(t *testing.T) { func TestPromoteGenerator_NeverDeployedEnvHandling(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, @@ -904,7 +904,7 @@ func TestPromoteGenerator_NeverDeployedEnvHandling(t *testing.T) { func TestPromoteGenerator_DeployDryRunCondition(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -921,7 +921,7 @@ func TestPromoteGenerator_DeployDryRunCondition(t *testing.T) { func TestPromoteGenerator_ManifestCommitOnlyIfChanged(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -941,7 +941,7 @@ func TestPromoteGenerator_ManifestCommitOnlyIfChanged(t *testing.T) { func TestResolveDeployInputs(t *testing.T) { cfg := &config.TrunkConfig{ - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -976,7 +976,7 @@ func TestResolveDeployInputs(t *testing.T) { func TestPreflightDeployMatrixOutputs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1058,7 +1058,7 @@ func TestPreflightDeployMatrixOutputs(t *testing.T) { func TestPreflightNoMatrixStepWhenNoInputs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "simple", @@ -1090,7 +1090,7 @@ func TestDeployJobsMatrixStrategy(t *testing.T) { name: "deploy with inputs uses matrix strategy", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1128,7 +1128,7 @@ func TestDeployJobsMatrixStrategy(t *testing.T) { name: "deploy without inputs uses single deploy", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "simple", @@ -1155,7 +1155,7 @@ func TestDeployJobsMatrixStrategy(t *testing.T) { name: "mixed deploy types", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1193,7 +1193,7 @@ func TestDeployJobsMatrixStrategy(t *testing.T) { name: "deploy with multiple custom inputs", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "infra", @@ -1281,7 +1281,7 @@ func TestPromoteGenerator_APIQueryStep(t *testing.T) { name: "Finalize using CLI instead of bash", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml"}, @@ -1308,7 +1308,7 @@ func TestPromoteGenerator_APIQueryStep(t *testing.T) { name: "Finalize with single deploy uses CLI", config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -1343,7 +1343,7 @@ func TestPromoteGenerator_APIQueryStep(t *testing.T) { func TestPromoteGenerator_CascadeDeployStateUpdate(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, }, @@ -1367,7 +1367,7 @@ func TestPromoteGenerator_CascadeDeployStateUpdate(t *testing.T) { func TestPromoteGenerator_PreflightUsesCLI(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml"}, @@ -1399,7 +1399,7 @@ func TestPromoteGenerator_PreflightUsesCLI(t *testing.T) { func TestPromoteGenerator_FinalizeUsesCLI(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Workflow: ".github/workflows/deploy.yaml"}, {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, @@ -1427,7 +1427,7 @@ func TestPromoteGenerator_FinalizeUsesCLI(t *testing.T) { func TestPromoteGenerator_RollbackJobs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml"}, @@ -1474,7 +1474,7 @@ func TestPromoteGenerator_RollbackJobs(t *testing.T) { func TestPromoteGenerator_NoRollbackWhenNoEnvironments(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{}, + Environments: config.EnvNames(), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, }, @@ -1506,7 +1506,7 @@ func TestPromoteGenerator_NoRollbackWhenNoEnvironments(t *testing.T) { func TestPromoteGenerator_RollbackOnFailureInput(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -1529,7 +1529,7 @@ func TestPromoteGenerator_RollbackOnFailureInput(t *testing.T) { func TestPromoteGenerator_ExternalDeployRollbackJobs(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy-app.yaml"}, }, @@ -1569,7 +1569,7 @@ func TestBuildDeployMatrix(t *testing.T) { { name: "multiple promotions with env_inputs overrides", config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "prod"}, + Environments: config.EnvNames("dev", "test", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1601,7 +1601,7 @@ func TestBuildDeployMatrix(t *testing.T) { { name: "single promotion", config: &config.TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "cdk", @@ -1626,7 +1626,7 @@ func TestBuildDeployMatrix(t *testing.T) { { name: "deploy not found returns empty matrix", config: &config.TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1645,7 +1645,7 @@ func TestBuildDeployMatrix(t *testing.T) { { name: "empty promotions returns empty matrix", config: &config.TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1662,7 +1662,7 @@ func TestBuildDeployMatrix(t *testing.T) { { name: "version substitution", config: &config.TrunkConfig{ - Environments: []string{"dev", "test"}, + Environments: config.EnvNames("dev", "test"), Deploys: []config.DeployConfig{ { Name: "app", @@ -1742,7 +1742,7 @@ on: cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1773,7 +1773,7 @@ func TestPromoteGenerator_NoPublishCallbackWhenNotConfigured(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -1797,7 +1797,7 @@ func TestPromoteGenerator_NoPublishCallbackWhenNotConfigured(t *testing.T) { func TestPromoteGenerator_HasConcurrencyBlock(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: config.EnvNames("dev", "staging", "prod"), } gen := NewPromoteGenerator(cfg, "") @@ -1816,7 +1816,7 @@ func TestPromoteGenerator_HasConcurrencyBlock(t *testing.T) { func TestPromoteGenerator_ConcurrencyOverride(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Concurrency: &config.ConcurrencyConfig{ Group: "my-custom-promote", CancelInProgress: true, @@ -1841,7 +1841,7 @@ func TestPromoteGenerator_ConcurrencyOverride(t *testing.T) { func TestPromoteGenerator_SelectiveDeploysAndForcePassthrough(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Workflow: "deploy-infra.yaml"}, {Name: "app", Workflow: "deploy-app.yaml"}, diff --git a/internal/generate/reconcile_companion_test.go b/internal/generate/reconcile_companion_test.go index 653492bf..a1d3548c 100644 --- a/internal/generate/reconcile_companion_test.go +++ b/internal/generate/reconcile_companion_test.go @@ -16,7 +16,7 @@ import ( func reconcileConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Reconcile: &config.ReconcileConfig{Enabled: true}, } } diff --git a/internal/generate/release.go b/internal/generate/release.go index 33a625eb..6dd2fa38 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -98,7 +98,7 @@ func (g *ReleaseGenerator) writeHeader(sb *strings.Builder) { // Document single environment sb.WriteString("# Environment: ") if len(g.config.Environments) > 0 { - sb.WriteString(g.config.Environments[0]) + sb.WriteString(g.config.Environments[0].Name) } sb.WriteString("\n#\n") @@ -161,7 +161,7 @@ func (g *ReleaseGenerator) writeJobs(sb *strings.Builder) { func (g *ReleaseGenerator) writePreflightJob(sb *strings.Builder) { env := "" if len(g.config.Environments) > 0 { - env = g.config.Environments[0] + env = g.config.Environments[0].Name } sb.WriteString(" preflight:\n") diff --git a/internal/generate/release_build_dispatch_test.go b/internal/generate/release_build_dispatch_test.go index 575d029e..9b4d2f7a 100644 --- a/internal/generate/release_build_dispatch_test.go +++ b/internal/generate/release_build_dispatch_test.go @@ -62,7 +62,7 @@ func TestPromoteGenerator_ReleaseBuildDispatch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Release: tt.release, } diff --git a/internal/generate/release_test.go b/internal/generate/release_test.go index be6f31c5..ba21f804 100644 --- a/internal/generate/release_test.go +++ b/internal/generate/release_test.go @@ -17,7 +17,7 @@ import ( func TestReleaseGenerator_NoRollbackJob(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -33,7 +33,7 @@ func TestReleaseGenerator_NoRollbackJob(t *testing.T) { func TestReleaseGenerator_Generate(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -65,7 +65,7 @@ func TestReleaseGenerator_Generate(t *testing.T) { func TestReleaseGenerator_ReleaseActions(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), } gen := NewReleaseGenerator(cfg, "") @@ -89,7 +89,7 @@ func TestReleaseGenerator_ReleaseActions(t *testing.T) { func TestReleaseGenerator_ValidYAML(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -116,7 +116,7 @@ func TestReleaseGenerator_ValidYAML(t *testing.T) { func TestReleaseGenerator_LatestReleaseStateUpdate(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -144,7 +144,7 @@ func TestReleaseGenerator_LatestReleaseStateUpdate(t *testing.T) { func TestReleaseGenerator_LatestReleaseStateUpdate_PushRetriesOnConflict(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -173,7 +173,7 @@ func TestReleaseGenerator_LatestReleaseStateUpdate_PushRetriesOnConflict(t *test func TestReleaseGenerator_DryRunSupport(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -191,7 +191,7 @@ func TestReleaseGenerator_DryRunSupport(t *testing.T) { func TestReleaseGenerator_SemverTagCalculation(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -213,7 +213,7 @@ func TestReleaseGenerator_SemverTagCalculation_CustomToken(t *testing.T) { emptySep := "" cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), TagGrammar: &config.TagGrammarConfig{ PreReleaseToken: &betaToken, PreReleaseSeparator: &emptySep, @@ -231,7 +231,7 @@ func TestReleaseGenerator_SemverTagCalculation_CustomToken(t *testing.T) { func TestReleaseGenerator_ChangelogGeneration(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -247,7 +247,7 @@ func TestReleaseGenerator_ChangelogGeneration(t *testing.T) { func TestReleaseGenerator_Summary(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -263,7 +263,7 @@ func TestReleaseGenerator_Summary(t *testing.T) { func TestReleaseGenerator_UsesCorrectActions(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -280,7 +280,7 @@ func TestReleaseGenerator_UsesCorrectActions(t *testing.T) { func TestReleaseGenerator_WithCLIVersion(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), CLIVersion: "v1.0.0", } @@ -303,7 +303,7 @@ func TestReleaseGenerator_WithCLIVersion(t *testing.T) { func TestReleaseGenerator_HasConcurrencyBlock(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -322,7 +322,7 @@ func TestReleaseGenerator_HasConcurrencyBlock(t *testing.T) { func TestReleaseGenerator_ConcurrencyOverride(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), Concurrency: &config.ConcurrencyConfig{ Group: "my-custom-release", CancelInProgress: true, @@ -339,7 +339,7 @@ func TestReleaseGenerator_ConcurrencyOverride(t *testing.T) { func TestReleaseGenerator_AllowBreakingChanges_BakesGateOff(t *testing.T) { // Default: the breaking-change gate reads the per-run workflow input. - def := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}} + def := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("prod")} defContent, err := NewReleaseGenerator(def, "").Generate() require.NoError(t, err) assert.Contains(t, defContent, "ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n") @@ -347,7 +347,7 @@ func TestReleaseGenerator_AllowBreakingChanges_BakesGateOff(t *testing.T) { // allow_breaking_changes: true bakes the gate off so a breaking release // proceeds even when the per-run input is unchecked. - on := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}, AllowBreakingChanges: true} + on := &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("prod"), AllowBreakingChanges: true} onContent, err := NewReleaseGenerator(on, "").Generate() require.NoError(t, err) assert.Contains(t, onContent, "ALLOW_BREAKING: \"true\"\n") diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 23527904..7458e9b7 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -198,7 +198,7 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { sb.WriteString(" required: true\n") sb.WriteString(" type: choice\n") sb.WriteString(" options:\n") - promoted := g.config.Environments + promoted := g.config.EnvironmentNames() if len(promoted) > 0 { promoted = promoted[1:] } diff --git a/internal/generate/rollback_test.go b/internal/generate/rollback_test.go index f7c02842..f749b86a 100644 --- a/internal/generate/rollback_test.go +++ b/internal/generate/rollback_test.go @@ -17,7 +17,7 @@ import ( func rollbackTestConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "services", @@ -32,13 +32,13 @@ func TestRollbackGenerator_Enabled_FalseWithOneEnv(t *testing.T) { // environment, which reverts via a merge to trunk, not a rollback. With no // promoted environment to roll back, the workflow is not emitted, mirroring // the hotfix generator. - cfg := &config.TrunkConfig{Environments: []string{"prod"}} + cfg := &config.TrunkConfig{Environments: config.EnvNames("prod")} g := NewRollbackGenerator(cfg, "") assert.False(t, g.Enabled()) } func TestRollbackGenerator_Enabled_TrueWithTwoEnvs(t *testing.T) { - cfg := &config.TrunkConfig{Environments: []string{"dev", "prod"}} + cfg := &config.TrunkConfig{Environments: config.EnvNames("dev", "prod")} g := NewRollbackGenerator(cfg, "") assert.True(t, g.Enabled()) } @@ -52,7 +52,7 @@ func TestRollbackGenerator_Enabled_FalseWithZeroEnv(t *testing.T) { func TestRollbackGenerator_EnvironmentChoices_ExcludeFirstEnv(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: config.EnvNames("dev", "staging", "prod"), Deploys: []config.DeployConfig{ {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, }, @@ -130,7 +130,7 @@ func TestRollbackGenerator_FinalizeNeedsWiring(t *testing.T) { func TestRollbackGenerator_FinalizeThreadsDeployResults(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, {Name: "web-api", Workflow: ".github/workflows/deploy-web-api.yaml"}, diff --git a/internal/generate/rollout_reserved_test.go b/internal/generate/rollout_reserved_test.go index af3ac4ef..8662f037 100644 --- a/internal/generate/rollout_reserved_test.go +++ b/internal/generate/rollout_reserved_test.go @@ -39,7 +39,7 @@ func TestRolloutReservedFieldsAreByteIdentical(t *testing.T) { cfgA := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -51,7 +51,7 @@ func TestRolloutReservedFieldsAreByteIdentical(t *testing.T) { cfgB := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", diff --git a/internal/generate/secrets_test.go b/internal/generate/secrets_test.go index 9d42dd6d..8374d002 100644 --- a/internal/generate/secrets_test.go +++ b/internal/generate/secrets_test.go @@ -19,7 +19,7 @@ const minimalWorkflowYAML = "on:\n workflow_call:\n" func orchestrateCfgWithDeploySecrets(secrets *config.SecretsConfig) (*config.TrunkConfig, string) { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{ { Name: "app", @@ -36,7 +36,7 @@ func orchestrateCfgWithDeploySecrets(secrets *config.SecretsConfig) (*config.Tru func orchestrateCfgWithBuildSecrets(secrets *config.SecretsConfig) (*config.TrunkConfig, string) { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ { Name: "app", @@ -205,7 +205,7 @@ func TestOrchestrateDeployCallbackJob_ExplicitSecretsMap(t *testing.T) { func TestPromoteDeployJob_ExplicitSecretsMap(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -236,7 +236,7 @@ func TestPromoteDeployJob_ExplicitSecretsMap(t *testing.T) { func TestPromoteDeployJob_NoSecretsByDefault(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -258,7 +258,7 @@ func TestPromoteDeployJob_NoSecretsByDefault(t *testing.T) { func TestPromoteDeployJob_InheritSecrets(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -282,7 +282,7 @@ func TestPromoteDeployJob_InheritSecrets(t *testing.T) { func hotfixCfgWithBuildSecrets(secrets *config.SecretsConfig) *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ { Name: "app", diff --git a/internal/generate/state_write_test.go b/internal/generate/state_write_test.go index 6c1a8dc8..fdf50173 100644 --- a/internal/generate/state_write_test.go +++ b/internal/generate/state_write_test.go @@ -50,7 +50,7 @@ func TestOrchestrateFinalizeDualStateWrite(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -77,7 +77,7 @@ func TestOrchestrateFinalizeStateTokenOverride(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, @@ -97,7 +97,7 @@ func TestOrchestrateFinalizeStateTokenOverride(t *testing.T) { func TestReleaseFinalizeDualStateWrite(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } gen := NewReleaseGenerator(cfg, "") @@ -116,7 +116,7 @@ func TestPromoteFinalizeStateTokenAuth(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), StateToken: "${{ secrets.CASCADE_BOT_TOKEN }}", } @@ -159,7 +159,7 @@ func TestOrchestrateFinalizeStampsBotAuthor(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -176,7 +176,7 @@ func TestOrchestrateFinalizeStampsBotAuthor(t *testing.T) { func TestReleaseFinalizeStampsBotAuthor(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"prod"}, + Environments: config.EnvNames("prod"), } content, err := NewReleaseGenerator(cfg, "").Generate() @@ -195,7 +195,7 @@ func TestStateWriteHonorsCustomGitIdentity(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Git: &config.GitConfig{ Mode: config.GitModeCustom, UserName: "release-bot", @@ -225,7 +225,7 @@ func TestStateWriteRetryCeilingAndConvergenceMarker(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -265,7 +265,7 @@ func TestStateWriteEmitsSkipCIMarker(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -316,7 +316,7 @@ func TestStateWriteBindsCASTokenToBaseBlob(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, @@ -355,7 +355,7 @@ func TestStateWriteNoEmDash(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden index e78ae498..b9fae232 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden @@ -10,9 +10,9 @@ # All intermediate environments updated with same artifact # Fails entirely if any step fails (no partial state) # -# Release states (based on position): -# prod (second-from-top) = prerelease -# canary (top) = released +# Release states (role, or position when no role is set): +# prod = prerelease +# canary = released # # Cascade targets (for cascade mode): # dev-to-staging - Promotes dev → staging diff --git a/internal/generate/versionoverrides_reserved_test.go b/internal/generate/versionoverrides_reserved_test.go index 827de874..fb8e4c40 100644 --- a/internal/generate/versionoverrides_reserved_test.go +++ b/internal/generate/versionoverrides_reserved_test.go @@ -18,7 +18,7 @@ func TestVersionOverridesReservedFieldIsByteIdentical(t *testing.T) { // Case A: reserved version_overrides pointer populated. cfgA := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", @@ -35,7 +35,7 @@ func TestVersionOverridesReservedFieldIsByteIdentical(t *testing.T) { // Case B: same base fields, reserved version_overrides block absent. cfgB := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ { Name: "app", diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index f8bbc499..e787f741 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -24,7 +24,7 @@ func writeManifest(t *testing.T) string { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Validate: &config.ValidateConfig{ Workflow: ".github/workflows/validate.yaml", }, @@ -53,7 +53,7 @@ func writeDivergedManifest(t *testing.T) string { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), } manifest := map[string]any{ config.DefaultManifestKey: config.CICDFile{ @@ -157,7 +157,7 @@ func writeCrossRepoManifest(t *testing.T) string { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Workflow: ".github/workflows/deploy.yaml"}, }, diff --git a/internal/hotfix/chain.go b/internal/hotfix/chain.go index 49d599f2..c7552454 100644 --- a/internal/hotfix/chain.go +++ b/internal/hotfix/chain.go @@ -88,7 +88,7 @@ func expandTargetEnvs(cfg *config.TrunkConfig, targetEnv string) ([]string, erro } // Environments[1..idx] inclusive: skip the first env, stop at the target. seq := make([]string, 0, idx) - seq = append(seq, cfg.Environments[1:idx+1]...) + seq = append(seq, cfg.EnvironmentNames()[1:idx+1]...) return seq, nil } diff --git a/internal/hotfix/chain_test.go b/internal/hotfix/chain_test.go index 12b88e23..1a64759f 100644 --- a/internal/hotfix/chain_test.go +++ b/internal/hotfix/chain_test.go @@ -122,7 +122,7 @@ func TestExpandTargetEnvs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &config.TrunkConfig{Environments: envs} + cfg := &config.TrunkConfig{Environments: config.EnvNames(envs...)} got, err := expandTargetEnvs(cfg, tt.targetEnv) if tt.wantErr { if err == nil { diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index 8602b1f8..95657730 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -824,11 +824,8 @@ func releaseToken() string { // isPrereleaseEnv reports whether env is the prerelease env (second from top), // mirroring promote's prerelease-env detection. func (f *Finalizer) isPrereleaseEnv(cfg *config.TrunkConfig, env string) bool { - envs := cfg.Environments - if len(envs) < 2 { - return false - } - return env == envs[len(envs)-2] + prerelease := cfg.PrereleaseEnvironment() + return prerelease != "" && env == prerelease } // writeConfig writes the updated manifest back to disk, rewriting only the diff --git a/internal/hotfix/finalize_options_test.go b/internal/hotfix/finalize_options_test.go index fb9ea9c1..63ceea17 100644 --- a/internal/hotfix/finalize_options_test.go +++ b/internal/hotfix/finalize_options_test.go @@ -146,13 +146,13 @@ func TestAllocateVersion(t *testing.T) { // TestIsPrereleaseEnv identifies the second-from-top env as the prerelease env. func TestIsPrereleaseEnv(t *testing.T) { f := &Finalizer{} - cfg := &config.TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}} + cfg := &config.TrunkConfig{Environments: config.EnvNames("dev", "test", "uat", "prod")} assert.True(t, f.isPrereleaseEnv(cfg, "uat"), "second-from-top is the prerelease env") assert.False(t, f.isPrereleaseEnv(cfg, "prod")) assert.False(t, f.isPrereleaseEnv(cfg, "dev")) - single := &config.TrunkConfig{Environments: []string{"prod"}} + single := &config.TrunkConfig{Environments: config.EnvNames("prod")} assert.False(t, f.isPrereleaseEnv(single, "prod"), "fewer than two envs has no prerelease env") } diff --git a/internal/initcmd/command_test.go b/internal/initcmd/command_test.go index 263f462c..d93ad23c 100644 --- a/internal/initcmd/command_test.go +++ b/internal/initcmd/command_test.go @@ -132,7 +132,7 @@ func TestRun_SuccessWritesValidScaffold(t *testing.T) { } require.NoError(t, scaffold.SelfCheck(onDisk)) - assert.Equal(t, []string{"dev", "prod"}, parsed.Config.Environments) + assert.Equal(t, []string{"dev", "prod"}, parsed.Config.EnvironmentNames()) } func TestRun_NoEnvOmitsDeployStub(t *testing.T) { @@ -202,7 +202,7 @@ func TestRun_CustomEnvsListedAndReleaseStageLast(t *testing.T) { parsed, err := config.ParseManifestFile(manifestPathFor(dir), config.DefaultManifestKey) require.NoError(t, err) - assert.Equal(t, []string{"staging", "production"}, parsed.Config.Environments) + assert.Equal(t, []string{"staging", "production"}, parsed.Config.EnvironmentNames()) } func TestRun_CLIVersionOverride(t *testing.T) { @@ -246,7 +246,7 @@ func TestRun_AllTopologiesOrderedManifestEnvs(t *testing.T) { } else { // Exact ordered equality: order is load-bearing because the last // environment is the release stage. - assert.Equal(t, tc.want, parsed.Config.Environments) + assert.Equal(t, tc.want, parsed.Config.EnvironmentNames()) } // The manifest that landed on disk must survive the real generator. diff --git a/internal/orchestrate/orchestrate_more_test.go b/internal/orchestrate/orchestrate_more_test.go index 02f0163d..55f191a4 100644 --- a/internal/orchestrate/orchestrate_more_test.go +++ b/internal/orchestrate/orchestrate_more_test.go @@ -177,7 +177,7 @@ func TestCalculateVersion_EnvironmentNotFound(t *testing.T) { o := &Orchestrator{ environment: "ghost", cicdFile: &config.CICDFile{ - Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}}, + Config: &config.TrunkConfig{Environments: config.EnvNames("dev", "prod")}, }, } @@ -198,7 +198,7 @@ func TestCalculateVersion_MultiEnv(t *testing.T) { environment: "dev", baseDir: repoDir, cicdFile: &config.CICDFile{ - Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}}, + Config: &config.TrunkConfig{Environments: config.EnvNames("dev", "prod")}, State: map[string]*config.EnvState{ "dev": {Version: "v1.0.0-rc.0"}, "prod": {Version: "v0.9.0", SHA: head}, diff --git a/internal/orchestrate/orchestrator.go b/internal/orchestrate/orchestrator.go index 715aa2ed..c81cddda 100644 --- a/internal/orchestrate/orchestrator.go +++ b/internal/orchestrate/orchestrator.go @@ -72,7 +72,7 @@ func NewOrchestrator(configPath, manifestKey, environment string, opts ...Option log.Debug("No environment specified and no environments configured - using state key: %s", environment) } - log.Debug("Environments: %v", cicdFile.Config.Environments) + log.Debug("Environments: %v", cicdFile.Config.EnvironmentNames()) log.Debug("Base directory: %s", baseDir) o := &Orchestrator{ @@ -399,7 +399,7 @@ func (o *Orchestrator) calculateVersion() (string, error) { return o.calculateComponentVersion() } - envs := o.cicdFile.Config.Environments + envs := o.cicdFile.Config.EnvironmentNames() // Get current environment's version and next env's version var currentDevVersion, nextEnvVersion, nextEnvSHA string @@ -594,7 +594,7 @@ func (o *Orchestrator) calculateComponentVersion() (string, error) { // 3. Initial commit: only when nothing has been released yet (truly the // first release). The changelog is then the full repo introduction. func (o *Orchestrator) calculateChangelogRefs() (string, string) { - envs := o.cicdFile.Config.Environments + envs := o.cicdFile.Config.EnvironmentNames() envIndex := indexOf(envs, o.environment) // 1. Intermediate env: compare against the next env's state. diff --git a/internal/orchestrate/orchestrator_test.go b/internal/orchestrate/orchestrator_test.go index 07083f5a..a6d2603c 100644 --- a/internal/orchestrate/orchestrator_test.go +++ b/internal/orchestrate/orchestrator_test.go @@ -387,7 +387,7 @@ func TestCalculateChangelogRefs(t *testing.T) { environment: tt.environment, cicdFile: &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: tt.envs, + Environments: config.EnvNames(tt.envs...), }, State: tt.state, LatestRelease: tt.latest, diff --git a/internal/orchestrate/tag_grammar_test.go b/internal/orchestrate/tag_grammar_test.go index 6e4dc922..2774be75 100644 --- a/internal/orchestrate/tag_grammar_test.go +++ b/internal/orchestrate/tag_grammar_test.go @@ -26,7 +26,7 @@ func TestCalculateVersion_CustomTagGrammar(t *testing.T) { t.Cleanup(func() { require.NoError(t, os.Chdir(orig)) }) cfg := &config.TrunkConfig{ - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), TagGrammar: &config.TagGrammarConfig{ Prefix: tgPtr("ver"), PreReleaseToken: tgPtr("beta"), diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 8e356984..a380b48d 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -35,7 +35,7 @@ func newRepo(t *testing.T) string { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "image", Workflow: ".github/workflows/image-build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/promote/breaking_gate_test.go b/internal/promote/breaking_gate_test.go index a259458a..70c01fb7 100644 --- a/internal/promote/breaking_gate_test.go +++ b/internal/promote/breaking_gate_test.go @@ -42,7 +42,7 @@ func TestCheckBreakingChangesForMode_ManifestGate(t *testing.T) { return NewPreflighter(PreflighterOptions{ Config: &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"staging"}, + Environments: config.EnvNames("staging"), AllowBreakingChanges: allow, }, State: map[string]*config.EnvState{ diff --git a/internal/promote/component_env_subset_test.go b/internal/promote/component_env_subset_test.go index 15211dd6..84f51f76 100644 --- a/internal/promote/component_env_subset_test.go +++ b/internal/promote/component_env_subset_test.go @@ -147,10 +147,10 @@ func TestApplyComponentLadder_EmptyComponentUnchanged(t *testing.T) { cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) require.NoError(t, err) - before := append([]string(nil), cicdFile.Config.Environments...) + before := append([]string(nil), cicdFile.Config.EnvironmentNames()...) require.NoError(t, applyComponentLadder(cicdFile, "")) - require.Equal(t, before, cicdFile.Config.Environments, "empty component must not narrow the ladder") - require.Equal(t, []string{"dev", "staging", "prod"}, cicdFile.Config.Environments) + require.Equal(t, before, cicdFile.Config.EnvironmentNames(), "empty component must not narrow the ladder") + require.Equal(t, []string{"dev", "staging", "prod"}, cicdFile.Config.EnvironmentNames()) } // TestApplyComponentLadder_NarrowsToComponentSubset proves the helper narrows the @@ -161,5 +161,5 @@ func TestApplyComponentLadder_NarrowsToComponentSubset(t *testing.T) { require.NoError(t, err) require.NoError(t, applyComponentLadder(cicdFile, "api")) - require.Equal(t, []string{"dev", "staging"}, cicdFile.Config.Environments) + require.Equal(t, []string{"dev", "staging"}, cicdFile.Config.EnvironmentNames()) } diff --git a/internal/promote/downgrade_test.go b/internal/promote/downgrade_test.go index cecf8eca..205d158d 100644 --- a/internal/promote/downgrade_test.go +++ b/internal/promote/downgrade_test.go @@ -13,7 +13,7 @@ func newDowngradePreflighter(t *testing.T, state map[string]*config.EnvState, al t.Helper() cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, }, State: state, diff --git a/internal/promote/env_role_test.go b/internal/promote/env_role_test.go new file mode 100644 index 00000000..5f293394 --- /dev/null +++ b/internal/promote/env_role_test.go @@ -0,0 +1,127 @@ +package promote + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// roleReleaseManifest declares a ladder where the release and prerelease stages +// are pinned by role: rather than by position. The list is [dev, staging, prod, +// monitor], but prod carries role: release and staging role: prerelease, so the +// release marker must land at the staging -> prod crossing, NOT at the +// positional last env (monitor) or the positional second-from-last (prod as +// prerelease). dev and staging are seeded at the same SHA so default mode's next +// logical step is the staging -> prod publish boundary. +const roleReleaseManifest = `ci: + config: + trunk_branch: main + environments: + - name: dev + - name: staging + role: prerelease + - name: prod + role: release + - name: monitor + state: + dev: + sha: sha1 + version: 1.0.0-rc.0 + staging: + sha: sha1 + version: 1.0.0-rc.0 +` + +func writeRoleReleaseManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(roleReleaseManifest), 0o644)) + return path +} + +func hasReleaseMarker(promos []EnvPromotion) bool { + for _, p := range promos { + if p.Environment == "release" { + return true + } + } + return false +} + +// TestCascade_RoleRelease_PublishesAtRoleEnv proves the cascade path honors an +// explicit role: release. A dev-to-prod cascade must insert the "release" marker +// promotion at the staging -> prod crossing (prod being the role:release env) and +// report the publish release action. Before the cascade path was routed through +// the role-aware accessors it treated the positional last env (monitor) as the +// release env, so crossing into the role env (prod) produced no release marker +// and the publish landed on the wrong environment. +func TestCascade_RoleRelease_PublishesAtRoleEnv(t *testing.T) { + path := writeRoleReleaseManifest(t) + + p, err := NewPromoter(PromoterOptions{ + ConfigPath: path, + DryRun: true, + Actor: "test-actor", + }) + require.NoError(t, err) + + result, err := p.Promote(ModeCascade, "dev-to-prod") + require.NoError(t, err) + require.True(t, result.Success, "dev-to-prod cascade should succeed; error: %s", result.Error) + + require.True(t, hasReleaseMarker(result.Promotions), + "cascade into the role:release env (prod) must insert a release marker promotion") + require.Equal(t, "publish", result.ReleaseAction, + "finishing at the role:release env must publish, not treat prod as an intermediate") + + // The release marker must sit immediately before the prod deploy, proving the + // publish boundary tracks the role env rather than the positional last env. + var relIdx, prodIdx = -1, -1 + for i, promo := range result.Promotions { + switch promo.Environment { + case "release": + relIdx = i + case "prod": + prodIdx = i + } + } + require.NotEqual(t, -1, relIdx, "release marker missing") + require.NotEqual(t, -1, prodIdx, "prod promotion missing") + require.Less(t, relIdx, prodIdx, "release marker must precede the prod deploy") +} + +// TestDefaultAndCascade_AgreeOnRoleMarkers proves single-step (default) and +// cascade modes place the release marker at the SAME role-tagged crossing for +// one role-annotated manifest. Default mode advancing from the seeded staging +// state hits the staging -> prod publish boundary, and cascade dev-to-prod +// crosses the same boundary; both must produce a "release" marker. A positional +// cascade path would disagree with the role-aware default path here. +func TestDefaultAndCascade_AgreeOnRoleMarkers(t *testing.T) { + pDefault, err := NewPromoter(PromoterOptions{ + ConfigPath: writeRoleReleaseManifest(t), + DryRun: true, + Actor: "test-actor", + }) + require.NoError(t, err) + defaultResult, err := pDefault.Promote(ModeDefault, "") + require.NoError(t, err) + require.True(t, defaultResult.Success, "default promotion should succeed; error: %s", defaultResult.Error) + + pCascade, err := NewPromoter(PromoterOptions{ + ConfigPath: writeRoleReleaseManifest(t), + DryRun: true, + Actor: "test-actor", + }) + require.NoError(t, err) + cascadeResult, err := pCascade.Promote(ModeCascade, "dev-to-prod") + require.NoError(t, err) + require.True(t, cascadeResult.Success, "cascade should succeed; error: %s", cascadeResult.Error) + + require.True(t, hasReleaseMarker(defaultResult.Promotions), + "default mode must place the release marker at the staging -> prod (role) boundary") + require.True(t, hasReleaseMarker(cascadeResult.Promotions), + "cascade mode must place the release marker at the same role boundary as default mode") +} diff --git a/internal/promote/guards_test.go b/internal/promote/guards_test.go index 5417454d..4e722b77 100644 --- a/internal/promote/guards_test.go +++ b/internal/promote/guards_test.go @@ -20,7 +20,7 @@ func writeGuardConfig(t *testing.T, environments []string, state map[string]*con cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: environments, + Environments: config.EnvNames(environments...), }, State: state, } @@ -130,7 +130,7 @@ func TestPromote_IntoDivergedEnv_MissingPatch_Blocked(t *testing.T) { } cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: state, } @@ -164,7 +164,7 @@ func TestPromote_IntoDivergedEnv_PatchesContained_Allowed(t *testing.T) { } cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: state, } @@ -197,7 +197,7 @@ func TestPromote_IntoDivergedEnv_Force_OverridesWithWarning(t *testing.T) { } cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: state, } diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index c9bb543a..59e96cae 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -200,7 +200,8 @@ func (p *Preflighter) Run() (*PreflightResult, error) { } // 4. Determine prerelease/final env and release marker positions - envs := p.cicdFile.Config.Environments + cfg := p.cicdFile.Config + envs := cfg.EnvironmentNames() var prereleaseEnv, prodEnv string hasReleaseMarker := false @@ -219,8 +220,11 @@ func (p *Preflighter) Run() (*PreflightResult, error) { prodEnv = envs[len(envs)-1] } } else if len(envs) >= 2 { - prereleaseEnv = envs[len(envs)-2] - prodEnv = envs[len(envs)-1] + // Role-aware, positional by default: an explicit role: on an entry moves + // the prerelease/release markers, otherwise the last/second-from-last + // entries stand in unchanged. + prereleaseEnv = cfg.PrereleaseEnvironment() + prodEnv = cfg.ReleaseEnvironment() } } @@ -404,12 +408,13 @@ func (p *Preflighter) detectDeployChanges(sourceSHA, targetEnv string) ([]string // Get source environment name for external deploy comparison var sourceEnv string - if len(p.cicdFile.Config.Environments) > 0 { - sourceEnv = p.cicdFile.Config.Environments[0] // Default to first env + envNames := p.cicdFile.Config.EnvironmentNames() + if len(envNames) > 0 { + sourceEnv = envNames[0] // Default to first env // Find the env before targetEnv for source - for i, env := range p.cicdFile.Config.Environments { + for i, env := range envNames { if env == targetEnv && i > 0 { - sourceEnv = p.cicdFile.Config.Environments[i-1] + sourceEnv = envNames[i-1] break } } diff --git a/internal/promote/preflight_test.go b/internal/promote/preflight_test.go index 107e8440..2733ffea 100644 --- a/internal/promote/preflight_test.go +++ b/internal/promote/preflight_test.go @@ -82,7 +82,7 @@ func TestPreflight_DefaultPromotion(t *testing.T) { // Setup test config with dev having state cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{"infra/**"}}, {Name: "app", Triggers: []string{"src/**"}}, @@ -112,7 +112,7 @@ func TestPreflight_CascadePromotion(t *testing.T) { // Setup test config with dev and test having different state cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{"infra/**"}}, {Name: "app", Triggers: []string{"src/**"}}, @@ -144,7 +144,7 @@ func TestPreflight_AllEnvironmentsInSync(t *testing.T) { // Setup test config where ALL environments including prod are in sync cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{"infra/**"}}, }, @@ -174,7 +174,7 @@ func TestPreflight_ExtractsPromotionFields(t *testing.T) { // Setup test config for testing field extraction cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{"infra/**"}}, {Name: "app", Triggers: []string{"src/**"}}, @@ -208,7 +208,7 @@ func TestPreflight_ExtractsPromotionFields(t *testing.T) { func TestPreflight_SourceImageDigest_FirstSortedNonEmpty(t *testing.T) { cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Triggers: []string{"src/**"}}, }, @@ -247,7 +247,7 @@ func TestPreflight_SourceImageDigest_FirstSortedNonEmpty(t *testing.T) { func TestPreflight_SourceImageDigest_EmptyWhenNoArtifact(t *testing.T) { cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{ {Name: "app", Triggers: []string{"src/**"}}, }, @@ -279,7 +279,7 @@ func TestPreflight_PrereleaseFinalEnv(t *testing.T) { // Test prerelease and final env detection cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, }, State: map[string]*config.EnvState{ @@ -319,7 +319,7 @@ func TestPreflight_DeployChecks(t *testing.T) { // Test that deploy checks filter deploys to run cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat"}, + Environments: config.EnvNames("dev", "test", "uat"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{"infra/**"}}, {Name: "app", Triggers: []string{"src/**"}}, @@ -352,7 +352,7 @@ func TestPreflight_ForceFlag(t *testing.T) { // Test that force flag is passed through cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, }, State: map[string]*config.EnvState{ @@ -377,7 +377,7 @@ func TestPreflight_RollbackSHA(t *testing.T) { // Test that rollback SHA is populated from target env's current state cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, }, State: map[string]*config.EnvState{ @@ -404,7 +404,7 @@ func TestPreflight_RollbackSHAEmpty_WhenNoTargetState(t *testing.T) { // Test that rollback SHA is empty when target env has no state (first deployment) cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, }, State: map[string]*config.EnvState{ @@ -432,7 +432,7 @@ func TestPreflight_DeploysFilter(t *testing.T) { // Test that deploys filter restricts which deploys are included cfg := &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat"}, + Environments: config.EnvNames("dev", "test", "uat"), Deploys: []config.DeployConfig{ {Name: "infra", Triggers: []string{}}, // No triggers = always deploy {Name: "app", Triggers: []string{}}, diff --git a/internal/promote/promote.go b/internal/promote/promote.go index 90ca2fe0..e8ce6d01 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -216,7 +216,7 @@ func (p *Promoter) Promote(mode PromotionMode, target string) (*PromotionResult, // If force=true, continues to next env even if current step fails. // State is committed for successful steps (partial commit on force+failure). func (p *Promoter) defaultPromotion() (*PromotionResult, error) { - envs := p.cicdFile.Config.Environments + envs := p.cicdFile.Config.EnvironmentNames() // Handle no-environment mode (library/CLI projects) // Use implicit environments: prerelease → release @@ -247,13 +247,16 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { // the prerelease env and prod where the publish action lands. Each // `mode: default` invocation advances state through the chain by one // logical step, stopping at the publish boundary. - prodEnv := envs[len(envs)-1] + // Role-aware, positional by default: role: on an environments entry moves the + // release/prerelease markers; unset roles keep the last/second-from-last + // positional defaults, so a manifest without roles behaves exactly as before. + prodEnv := p.cicdFile.Config.ReleaseEnvironment() releaseIdx := indexOf(envs, "release") var prereleaseEnv string if releaseIdx > 0 { prereleaseEnv = envs[releaseIdx-1] } else if len(envs) >= 2 { - prereleaseEnv = envs[len(envs)-2] + prereleaseEnv = p.cicdFile.Config.PrereleaseEnvironment() } result := &PromotionResult{ @@ -608,7 +611,7 @@ func (p *Promoter) cascadePromotion(target string) (*PromotionResult, error) { }, nil } - envs := p.cicdFile.Config.Environments + envs := p.cicdFile.Config.EnvironmentNames() sourceIdx := indexOf(envs, sourceEnv) targetIdx := indexOf(envs, targetEnv) @@ -647,16 +650,19 @@ func (p *Promoter) cascadePromotion(target string) (*PromotionResult, error) { // [envs..., "release", prodEnv]. Cascade walks this chain atomically; // when it crosses into the prod env, a "release" promotion is inserted // before the prod promotion (release marker advance + then deploy). + // Role-aware, positional by default: role: on an environments entry moves the + // prerelease/release markers, matching the default (single-step) path so both + // modes agree on where prerelease and release land for the same manifest. releaseIdx := indexOf(envs, "release") var prereleaseEnv, publishEnv string if releaseIdx > 0 { prereleaseEnv = envs[releaseIdx-1] publishEnv = "release" } else if len(envs) >= 2 { - prereleaseEnv = envs[len(envs)-2] - publishEnv = envs[len(envs)-1] + prereleaseEnv = p.cicdFile.Config.PrereleaseEnvironment() + publishEnv = p.cicdFile.Config.ReleaseEnvironment() } - prodEnv := envs[len(envs)-1] + prodEnv := p.cicdFile.Config.ReleaseEnvironment() semVersion := p.stripPreRelease(sourceState.Version) // Build promotions for envs[sourceIdx+1..targetIdx]. Materialize "release" diff --git a/internal/promote/promote_test.go b/internal/promote/promote_test.go index f8d3ab38..d4c74d8e 100644 --- a/internal/promote/promote_test.go +++ b/internal/promote/promote_test.go @@ -73,7 +73,7 @@ func TestDefaultPromotion(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: tt.environments, + Environments: config.EnvNames(tt.environments...), }, State: tt.state, } @@ -217,7 +217,7 @@ func TestCascadePromotion(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: tt.state, } @@ -293,7 +293,7 @@ func TestDefaultPromotion_StripsRCSuffixOnPublishEnv(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: map[string]*config.EnvState{ "dev": {SHA: "abc123", Version: "v1.0.0-rc.0"}, @@ -359,7 +359,7 @@ func TestCascadePromotion_StripsRCSuffixOnPublishEnv(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: map[string]*config.EnvState{ "dev": {SHA: "abc123", Version: "v1.0.0-rc.0"}, @@ -422,7 +422,7 @@ func TestCascadePromotion_AtomicBehavior(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "master", - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), }, State: map[string]*config.EnvState{ "dev": {SHA: "new-sha", Version: "v1.0.0-rc.0"}, diff --git a/internal/promote/tag_grammar_test.go b/internal/promote/tag_grammar_test.go index 195fbc49..2ca99093 100644 --- a/internal/promote/tag_grammar_test.go +++ b/internal/promote/tag_grammar_test.go @@ -14,7 +14,7 @@ func strPtr(s string) *string { return &s } func betaConfig(state map[string]*config.EnvState) *config.CICDFile { return &config.CICDFile{ Config: &config.TrunkConfig{ - Environments: []string{"dev", "test", "uat", "prod"}, + Environments: config.EnvNames("dev", "test", "uat", "prod"), Deploys: []config.DeployConfig{{Name: "app"}}, TagGrammar: &config.TagGrammarConfig{ PreReleaseToken: strPtr("beta"), diff --git a/internal/reset/reset_test.go b/internal/reset/reset_test.go index 620e1aa2..b505af11 100644 --- a/internal/reset/reset_test.go +++ b/internal/reset/reset_test.go @@ -332,7 +332,7 @@ func TestWriteConfig(t *testing.T) { cicdFile := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), }, } diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index c7bcdb54..976b9112 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -146,13 +146,13 @@ func New(opts Options) (*Rollbacker, error) { // ladder, so the guards behave byte-identically to before. var environments []string if cicdFile.Config != nil { - environments = cicdFile.Config.Environments + environments = cicdFile.Config.EnvironmentNames() if opts.Component != "" { resolved, err := cicdFile.Config.ResolveComponent(opts.Component) if err != nil { return nil, fmt.Errorf("resolving component %q: %w", opts.Component, err) } - environments = resolved.Config.Environments + environments = resolved.Config.EnvironmentNames() } } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index e63ae2c5..e5ce52f4 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -99,7 +99,7 @@ func TestScaffold_ManifestParsesValidatesAndGenerates(t *testing.T) { // Positional env ordering preserved. A release-only manifest omits // the environments key, so the parsed slice is nil there. if len(tc.envs) > 0 { - require.Equal(t, tc.envs, parsed.Config.Environments) + require.Equal(t, tc.envs, parsed.Config.EnvironmentNames()) } else { require.Empty(t, parsed.Config.Environments) } diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index d9657bb8..f281cf54 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -82,8 +82,8 @@ }, "environments": { "type": "array", - "items": { "type": "string" }, - "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + "items": { "$ref": "#/definitions/environmentEntry" }, + "description": "Ordered promotion ladder. Each entry is a bare name (sugar) or an object with the environment's name, optional role, and inline settings. Empty means a no-environment setup (library or CLI projects)." }, "cli_version": { "type": "string", @@ -181,11 +181,6 @@ "description": "Explicit action reference pins keyed by action name." }, "telemetry": { "$ref": "#/definitions/telemetryConfig" }, - "environment_config": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, - "description": "Per-environment settings keyed by environment name." - }, "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, @@ -527,7 +522,7 @@ "properties": { "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, - "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "environments": { "type": "array", "items": { "$ref": "#/definitions/environmentEntry" }, "description": "Overrides the shared promotion ladder for this component (bare names or objects with inline settings); whole-replaces the inherited list." }, "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, "validate": { "$ref": "#/definitions/validateConfig" }, @@ -547,7 +542,6 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "rollback": { "$ref": "#/definitions/rollbackConfig" }, "deployments": { "$ref": "#/definitions/deploymentsConfig" }, - "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, "extra_paths": { "type": "array", "items": { "type": "string" }, "description": "Repo-relative globs beyond this component's own path that both fire its orchestrate workflow and count toward its version bump, so a change to a shared dependency this component consumes bumps it correctly. Additive to path and to any top-level shared_paths." }, "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, @@ -822,11 +816,28 @@ "secret_name": { "type": "string", "description": "Name of a GitHub Actions secret holding the auth token (a reference, never an inline token). Reserved." } } }, + "environmentEntry": { + "oneOf": [ + { "type": "string", "description": "Environment name; sugar for an environment with no inline settings." }, + { "$ref": "#/definitions/environmentConfig" } + ], + "description": "One entry in the environments ladder: a bare name or an object carrying the name, an optional role, and inline per-environment settings." + }, "environmentConfig": { "type": "object", "additionalProperties": false, - "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.", + "required": ["name"], + "description": "An environments entry in object form: the environment name, an optional promotion role, and the inline per-environment settings the cascade environments command emits as an operator-appliable GitHub Environments REST config.", "properties": { + "name": { + "type": "string", + "description": "Environment name. Keys job IDs and expression references and defines this entry's rung on the promotion ladder." + }, + "role": { + "type": "string", + "enum": ["prerelease", "release"], + "description": "Optional explicit promotion stage, overriding the positional default (last entry = release, second-from-last = prerelease)." + }, "gha_environment": { "type": "string", "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)." diff --git a/internal/simulate/engine_deploy_stub_test.go b/internal/simulate/engine_deploy_stub_test.go index 58532f7b..553e96ac 100644 --- a/internal/simulate/engine_deploy_stub_test.go +++ b/internal/simulate/engine_deploy_stub_test.go @@ -19,7 +19,7 @@ func seedManifestWithCallbacks(t *testing.T) string { return writeManifest(t, &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "uat", "prod"}, + Environments: config.EnvNames("dev", "uat", "prod"), Builds: []config.BuildConfig{ {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/simulate/engine_test.go b/internal/simulate/engine_test.go index aa4f4798..22060826 100644 --- a/internal/simulate/engine_test.go +++ b/internal/simulate/engine_test.go @@ -25,7 +25,7 @@ func seedManifest(t *testing.T) string { cicd := &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "uat", "prod"}, + Environments: config.EnvNames("dev", "uat", "prod"), }, State: map[string]*config.EnvState{ "dev": { @@ -103,7 +103,7 @@ func TestEngine_Simulate_PromoteCascade(t *testing.T) { // dev populated, uat and prod empty: a cascade to prod carries dev's state // through every intermediate hop atomically. path := writeManifest(t, &config.CICDFile{ - Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "uat", "prod"}}, + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "uat", "prod")}, State: map[string]*config.EnvState{ "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.2.0-rc.1"}, "uat": {}, diff --git a/internal/simulate/hotfix_action_test.go b/internal/simulate/hotfix_action_test.go index ee61ce96..852944ed 100644 --- a/internal/simulate/hotfix_action_test.go +++ b/internal/simulate/hotfix_action_test.go @@ -19,7 +19,7 @@ func seedHotfixManifest(t *testing.T) string { return writeManifest(t, &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "uat", "prod"}, + Environments: config.EnvNames("dev", "uat", "prod"), }, State: map[string]*config.EnvState{ "uat": { diff --git a/internal/simulate/release_action_test.go b/internal/simulate/release_action_test.go index ecfb2d9f..e7a32a1a 100644 --- a/internal/simulate/release_action_test.go +++ b/internal/simulate/release_action_test.go @@ -24,7 +24,7 @@ func TestReleaseAction_SurfacesPrereleaseMarker(t *testing.T) { // dev populated, uat (the prerelease env) empty: the default crossing into // uat sets ReleaseAction=prerelease. path := writeManifest(t, &config.CICDFile{ - Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "uat", "prod"}}, + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "uat", "prod")}, State: map[string]*config.EnvState{ "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.0.0-rc.0"}, "uat": {}, @@ -51,7 +51,7 @@ func TestReleaseAction_ErrorsWhenNoCrossing(t *testing.T) { // A normal early hop that does not reach the prerelease env: dev advances to // staging, but staging is not the release boundary, so no marker is emitted. path := writeManifest(t, &config.CICDFile{ - Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "staging", "uat", "prod"}}, + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "staging", "uat", "prod")}, State: map[string]*config.EnvState{ "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.0.0-rc.0"}, "staging": {}, diff --git a/internal/simulate/rollback_action_test.go b/internal/simulate/rollback_action_test.go index 1c7c1cd5..55d118bf 100644 --- a/internal/simulate/rollback_action_test.go +++ b/internal/simulate/rollback_action_test.go @@ -34,7 +34,7 @@ func seedRollbackManifest(t *testing.T) string { return writeManifest(t, &config.CICDFile{ Config: &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "uat", "prod"}, + Environments: config.EnvNames("dev", "uat", "prod"), }, State: map[string]*config.EnvState{ "prod": { @@ -94,7 +94,7 @@ func TestRollbackAction_NoOpWhenAlreadyAtTarget(t *testing.T) { // No distinct prior: the only snapshot equals the current SHA, so the // resolved target is the current state and the rollback is a no-op. path := writeManifest(t, &config.CICDFile{ - Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}}, + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: config.EnvNames("dev", "prod")}, State: map[string]*config.EnvState{ "prod": { SHA: "samesha000000", diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index 13c7c6c4..2695235b 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -38,7 +38,7 @@ func newRepo(t *testing.T) string { cfg := &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: config.EnvNames("dev", "staging", "prod"), Builds: []config.BuildConfig{ {Name: "image", Workflow: ".github/workflows/image-build.yaml", Triggers: []string{"src/**"}}, }, diff --git a/internal/version/command.go b/internal/version/command.go index ec0dd6fa..ed55482c 100644 --- a/internal/version/command.go +++ b/internal/version/command.go @@ -123,7 +123,8 @@ Examples: // out unchanged. func singleComponentVersionInputs(cfg *config.TrunkConfig, configPath, environment, baseSHA, headSHA string) (currentDevVersion, nextEnvVersion string, commits []changelog.ConventionalCommit, calc *Calculator, err error) { envIndex := -1 - for i, env := range cfg.Environments { + envNames := cfg.EnvironmentNames() + for i, env := range envNames { if env == environment { envIndex = i break @@ -139,8 +140,8 @@ func singleComponentVersionInputs(cfg *config.TrunkConfig, configPath, environme if state, ok := cicdFile.State[environment]; ok { currentDevVersion = state.Version } - if envIndex+1 < len(cfg.Environments) { - nextEnv := cfg.Environments[envIndex+1] + if envIndex+1 < len(envNames) { + nextEnv := envNames[envIndex+1] if state, ok := cicdFile.State[nextEnv]; ok { nextEnvVersion = state.Version nextEnvSHA = state.SHA diff --git a/internal/visualize/crossrepo_test.go b/internal/visualize/crossrepo_test.go index 81589ddf..3048da3d 100644 --- a/internal/visualize/crossrepo_test.go +++ b/internal/visualize/crossrepo_test.go @@ -15,7 +15,7 @@ import ( func primaryWithDependents() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "api", Workflow: ".github/workflows/build-api.yaml"}, }, @@ -126,7 +126,7 @@ func TestBuildCrossRepoViewModel_SatelliteOnly_NotifiesNamedPrimary(t *testing.T // must still render its notify edge to the named primary repo. vm, err := BuildCrossRepoViewModel(&config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev"}, + Environments: config.EnvNames("dev"), Deploys: []config.DeployConfig{{Name: "app", Workflow: ".github/workflows/deploy.yaml"}}, Notify: &config.NotifyConfig{Repo: "org/my-backend"}, }) diff --git a/internal/visualize/env.go b/internal/visualize/env.go index 67c22f44..028dcb34 100644 --- a/internal/visualize/env.go +++ b/internal/visualize/env.go @@ -41,21 +41,22 @@ func BuildEnvViewModel(cfg *config.TrunkConfig, state map[string]*config.EnvStat Node{ID: endNodeID, Kind: NodeEnd}, ) - for _, env := range cfg.Environments { + envs := cfg.EnvironmentNames() + for _, env := range envs { vm.Nodes = append(vm.Nodes, Node{ID: env, Label: env, Kind: NodeEnv}) } // The entry transition lands on the first environment; the exit transition // leaves the last one. - first := cfg.Environments[0] - last := cfg.Environments[len(cfg.Environments)-1] + first := envs[0] + last := envs[len(envs)-1] vm.Edges = append(vm.Edges, Edge{From: startNodeID, To: first, Kind: EdgeTransition}) // Promotion transitions chain each environment to the next in order. - for i := 0; i+1 < len(cfg.Environments); i++ { + for i := 0; i+1 < len(envs); i++ { vm.Edges = append(vm.Edges, Edge{ - From: cfg.Environments[i], - To: cfg.Environments[i+1], + From: envs[i], + To: envs[i+1], Kind: EdgePromote, Label: "promote", }) @@ -64,7 +65,7 @@ func BuildEnvViewModel(cfg *config.TrunkConfig, state map[string]*config.EnvStat // Divergence branches are appended after the linear ladder so the chain reads // top to bottom before the hotfix detours, keeping the model order stable. - for i, env := range cfg.Environments { + for i, env := range envs { es := state[env] if !es.IsDiverged() { continue @@ -74,8 +75,8 @@ func BuildEnvViewModel(cfg *config.TrunkConfig, state map[string]*config.EnvStat vm.Edges = append(vm.Edges, Edge{From: env, To: hotfixID, Kind: EdgeDiverge, Label: "diverge"}) rejoinTo := endNodeID - if i+1 < len(cfg.Environments) { - rejoinTo = cfg.Environments[i+1] + if i+1 < len(envs) { + rejoinTo = envs[i+1] } vm.Edges = append(vm.Edges, Edge{From: hotfixID, To: rejoinTo, Kind: EdgeRejoin, Label: "rejoin"}) } diff --git a/internal/visualize/env_test.go b/internal/visualize/env_test.go index 339ba9f1..720eaa4f 100644 --- a/internal/visualize/env_test.go +++ b/internal/visualize/env_test.go @@ -14,7 +14,7 @@ import ( func envConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "staging", "prod"}, + Environments: config.EnvNames("dev", "staging", "prod"), } } diff --git a/internal/visualize/stages_test.go b/internal/visualize/stages_test.go index e5a29f87..ae70516a 100644 --- a/internal/visualize/stages_test.go +++ b/internal/visualize/stages_test.go @@ -15,7 +15,7 @@ import ( func stagesConfig() *config.TrunkConfig { return &config.TrunkConfig{ TrunkBranch: "main", - Environments: []string{"dev", "prod"}, + Environments: config.EnvNames("dev", "prod"), Builds: []config.BuildConfig{ {Name: "api", Workflow: ".github/workflows/build-api.yaml"}, {Name: "web", Workflow: ".github/workflows/build-web.yaml"}, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index d9657bb8..f281cf54 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -82,8 +82,8 @@ }, "environments": { "type": "array", - "items": { "type": "string" }, - "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + "items": { "$ref": "#/definitions/environmentEntry" }, + "description": "Ordered promotion ladder. Each entry is a bare name (sugar) or an object with the environment's name, optional role, and inline settings. Empty means a no-environment setup (library or CLI projects)." }, "cli_version": { "type": "string", @@ -181,11 +181,6 @@ "description": "Explicit action reference pins keyed by action name." }, "telemetry": { "$ref": "#/definitions/telemetryConfig" }, - "environment_config": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, - "description": "Per-environment settings keyed by environment name." - }, "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, @@ -527,7 +522,7 @@ "properties": { "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, - "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "environments": { "type": "array", "items": { "$ref": "#/definitions/environmentEntry" }, "description": "Overrides the shared promotion ladder for this component (bare names or objects with inline settings); whole-replaces the inherited list." }, "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, "validate": { "$ref": "#/definitions/validateConfig" }, @@ -547,7 +542,6 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "rollback": { "$ref": "#/definitions/rollbackConfig" }, "deployments": { "$ref": "#/definitions/deploymentsConfig" }, - "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, "extra_paths": { "type": "array", "items": { "type": "string" }, "description": "Repo-relative globs beyond this component's own path that both fire its orchestrate workflow and count toward its version bump, so a change to a shared dependency this component consumes bumps it correctly. Additive to path and to any top-level shared_paths." }, "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, @@ -822,11 +816,28 @@ "secret_name": { "type": "string", "description": "Name of a GitHub Actions secret holding the auth token (a reference, never an inline token). Reserved." } } }, + "environmentEntry": { + "oneOf": [ + { "type": "string", "description": "Environment name; sugar for an environment with no inline settings." }, + { "$ref": "#/definitions/environmentConfig" } + ], + "description": "One entry in the environments ladder: a bare name or an object carrying the name, an optional role, and inline per-environment settings." + }, "environmentConfig": { "type": "object", "additionalProperties": false, - "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.", + "required": ["name"], + "description": "An environments entry in object form: the environment name, an optional promotion role, and the inline per-environment settings the cascade environments command emits as an operator-appliable GitHub Environments REST config.", "properties": { + "name": { + "type": "string", + "description": "Environment name. Keys job IDs and expression references and defines this entry's rung on the promotion ladder." + }, + "role": { + "type": "string", + "enum": ["prerelease", "release"], + "description": "Optional explicit promotion stage, overriding the positional default (last entry = release, second-from-last = prerelease)." + }, "gha_environment": { "type": "string", "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)."