From f9cc307168a180739a277a36d07cbb08f6328211 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 21 Jun 2026 07:46:53 -0400 Subject: [PATCH 1/2] feat: emit native GitHub Deployment objects for promotions Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 14 ++ docs/src/content/docs/architecture.md | 15 ++- docs/src/content/docs/configuration.md | 30 +++++ e2e/scenarios/31-native-deployments.yaml | 51 +++++++ internal/config/native_deployments_test.go | 46 +++++++ internal/config/schema_v1.go | 14 ++ internal/config/types.go | 38 +++--- internal/generate/generator.go | 43 ++++++ internal/generate/native_deployments.go | 117 ++++++++++++++++ internal/generate/native_deployments_test.go | 134 +++++++++++++++++++ internal/generate/promote.go | 31 +++++ internal/schema/manifest.schema.json | 14 ++ schema/manifest.schema.json | 14 ++ 13 files changed, 536 insertions(+), 25 deletions(-) create mode 100644 e2e/scenarios/31-native-deployments.yaml create mode 100644 internal/config/native_deployments_test.go create mode 100644 internal/generate/native_deployments.go create mode 100644 internal/generate/native_deployments_test.go diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 23a16d6..4c6693d 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -151,6 +151,7 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, "drift_check": { "$ref": "#/definitions/driftCheckConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, "pin_mode": { "type": "string", "enum": ["tag", "sha"], @@ -667,6 +668,15 @@ "comment": { "type": "boolean" } } }, + "deploymentsConfig": { + "type": "object", + "additionalProperties": false, + "description": "Opt-in GitHub Deployments API integration. When enabled, the finalize job creates a Deployment per target environment and reports in_progress then success/failure status after each deploy.", + "properties": { + "enabled": { "type": "boolean", "description": "Activates GitHub Deployments API reporting in the finalize job." }, + "keep_prior_active": { "type": "boolean", "description": "Prevents GitHub from auto-inactivating prior deployments for the same environment (auto_inactive:false). Default false relies on native auto-inactivation." } + } + }, "telemetryConfig": { "type": "object", "additionalProperties": false, @@ -731,6 +741,10 @@ "type": "array", "items": { "type": "string" }, "description": "Expected env-scoped variable NAMES (names only, never values)." + }, + "environment_url": { + "type": "string", + "description": "URL of the deployed environment, reported to GitHub Deployments API status updates." } } }, diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index d9849c4..623fdd0 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -564,19 +564,19 @@ For satellite repos with notify config: ### What cascade does today -The generator emits an `environment: ` key on each deploy job whenever the manifest includes an `environments` list. That single key is enough for GitHub Actions to attach deployment records, honour required-reviewer gates, apply wait timers, and scope environment secrets. You configure all of that inside GitHub, not in the manifest. No cascade code calls the Deployments REST API or the Environments REST API directly. +The generator emits an `environment: ` key on each deploy job whenever the manifest includes an `environments` list. That single key is enough for GitHub Actions to attach deployment records, honour required-reviewer gates, apply wait timers, and scope environment secrets. You configure all of that inside GitHub, not in the manifest. -### What is deferred +When you opt in with `deployments.enabled: true`, the finalize job also reports deployment status through the Deployments API: it calls `POST /repos/{owner}/{repo}/deployments` to create a Deployment for the runtime-selected environment, `POST /repos/{owner}/{repo}/deployments/{id}/statuses` to mark it `in_progress`, then a terminal `success` or `failure` status once the deploy callbacks finish. See [Native deployments](/cascade/configuration/#native-deployments-opt-in) for the toggle and the per-environment `environment_url`. -Two capabilities are intentionally out of scope for v1: +### What is deferred -- Programmatic Deployments API status. cascade does not call `POST /repos/{owner}/{repo}/deployments` or `POST /repos/{owner}/{repo}/deployments/{id}/statuses`. GitHub Actions creates these records automatically when a job carries `environment:`, so adopters get deployment records without cascade owning that call. +One capability remains out of scope for v1: - Environments REST configuration sync. cascade does not CALL the Environments REST API: it never reads or writes environment protection rules (required reviewers, wait timers, branch policies) over the wire. The manifest can now EXPRESS that configuration, and `cascade environments` emits it as an operator-appliable file (apply with `gh api` or Terraform), but applying it stays an operator step. cascade emits; the operator applies. ### Why deferred -Keeping cascade out of these APIs in v1 bounds the surface area and avoids coupling the tool to GitHub API semantics that are still evolving. The auto-created deployment records from `environment:` already cover the common case. Adding programmatic control before an adopter needs it would buy complexity and nothing else. If those APIs change shape, cascade would have to track the change even though nothing in v1 depends on them. +Keeping cascade out of the Environments REST API in v1 bounds the surface area and avoids coupling the tool to GitHub API semantics that are still evolving. Adding programmatic control before an adopter needs it would buy complexity and nothing else. If that API changes shape, cascade would have to track the change even though nothing in v1 depends on it. ### How the design reserves the extension points @@ -597,19 +597,20 @@ config: tag_patterns: [v*] # custom policy only secrets: [MY_SECRET] # expected env-scoped secret names variables: [REGION] # expected env-scoped variable names + environment_url: https://... # reported on the Deployment status (native deployments) ``` The protection fields (`required_reviewers`, `wait_timer`, `branch_policy`, `branch_patterns`, `tag_patterns`) and the expected `secrets` and `variables` names are real, additive fields, not reserved placeholders. `cascade environments` reads them and emits an operator-appliable file (see [environments](/cascade/cli-reference/#environments)). cascade still never calls the REST API: it forms the PUT body it can fully express from the manifest and surfaces the rest, including the reviewer slugs and the secret and variable names, under `operator_todo` for the operator to apply. The `environments` list stays a plain ordered `[]string`; the separate `environment_config` map carries per-env settings. Adding fields under `environment_config.` is additive and never touches the ordering semantics of `environments`. A manifest that omits `environment_config` entirely is valid and equivalent to today's behaviour. -**Single finalize seam.** The `orchestrate.Finalize` and `promote.Finalize` functions are the only places that write state after a deployment completes. A future Deployments API call attaches at one of those two points, not scattered across the generator. That code constraint is already in place. +**Single finalize seam.** The `orchestrate.Finalize` and `promote.Finalize` functions are the only places that write state after a deployment completes. The Deployments API status reporting attaches at exactly those two points, not scattered across the generator. **Generator delegates environment semantics to GitHub.** Because the generator emits `environment:` and nothing more, it does not embed logic about what that environment means. Programmatic status reporting slots in at finalize time; Environments REST configuration sync is a separate operational concern that never needs to touch the generator. ### Forward-compatibility guarantee -Both capabilities, when they arrive, will follow the same additive-only policy described in [Versioning & Schema](/cascade/versioning/): new optional fields under `environment_config.`, new optional top-level blocks if needed, and no removal or re-typing of existing fields. Neither will require a `schema_version` bump. Manifests that do not opt in to the new fields continue to work exactly as they do today. +Environments REST configuration sync, when it arrives, will follow the same additive-only policy described in [Versioning & Schema](/cascade/versioning/): new optional fields under `environment_config.`, new optional top-level blocks if needed, and no removal or re-typing of existing fields. It will not require a `schema_version` bump. The Deployments API status reporting already shipped under that same policy: `deployments` and `environment_url` are additive opt-in fields that did not bump the schema version. Manifests that do not opt in to the new fields continue to work exactly as they do today. ## Testing Strategy diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 177ff20..62c039e 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -499,6 +499,36 @@ Behavior: - **Trusted PR resolution.** The companion derives the target pull-request number only from trusted `workflow_run` run metadata (the source run's `pull_requests` array, or a head-SHA lookup for fork pull requests), never from the artifact the pull-request job uploads. A fork therefore cannot redirect the comment at another pull request. - **cascade-owned.** Both files carry the cascade-generated marker, so `cascade verify` itself tracks them: edit them by hand and they are reported as drift; remove the toggle and they are reported as orphans. +### Native deployments (opt-in) + +Set `deployments.enabled: true` and the finalize job reports deployment status through the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments). It creates a Deployment for the environment selected at run time, marks it `in_progress`, then reports a terminal `success` or `failure` status once the deploy callbacks finish. Pair it with a per-environment `environment_url` so the Deployment status links straight to the running environment. + +```yaml +ci: + config: + environments: [production] + deployments: + enabled: true + keep_prior_active: false + environment_config: + production: + environment_url: "https://app.example.com" +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `deployments.enabled` | bool | false | Create a Deployment and report status from the finalize job | +| `deployments.keep_prior_active` | bool | false | Set `auto_inactive: false` so GitHub leaves prior deployments for the same environment Active. Default relies on GitHub's native auto-inactivation | +| `environment_config..environment_url` | string | "" | URL reported on the Deployment status for that environment | + +Behavior: + +- **Status transition model.** The finalize job runs after every deploy callback, so it owns the full lifecycle: create the Deployment, set `in_progress`, then set `success` or `failure` based on whether every deploy callback succeeded. The terminal status step runs under `always()` so a failed deploy still reports `failure` instead of leaving the Deployment stuck at `in_progress`. +- **Per-environment URL.** `environment_url` is resolved at run time from `environment_config..environment_url` for the environment being deployed. Environments without a configured URL report an empty URL. +- **Guarded to real GitHub.** Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea (which have no Deployments API) the steps are skipped and the workflow stays runnable. +- **Least-privilege scope.** The toggle adds `deployments: write` to the workflow's top-level permissions only when enabled; the OFF-state output is unchanged. +- **Opt-in and additive.** Omit `deployments` and nothing is emitted. The field did not bump `schema_version`. + ## State Section The `state` section tracks deployment state per environment plus a synthetic `release` slot. The framework manages it automatically. Do not hand-edit. diff --git a/e2e/scenarios/31-native-deployments.yaml b/e2e/scenarios/31-native-deployments.yaml new file mode 100644 index 0000000..fc129ef --- /dev/null +++ b/e2e/scenarios/31-native-deployments.yaml @@ -0,0 +1,51 @@ +name: "Native Deployments" +description: | + Exercises the opt-in GitHub Deployments API integration + (config.deployments.enabled) and the per-environment environment_url. When + enabled, the finalize job creates a GitHub Deployment for the runtime-selected + environment, marks it in_progress, then reports a terminal success or failure + status. The finalize job also requests deployments: write so it can create the + Deployment and post status updates. + + Each Deployments API step is guarded by github.server_url == 'https://github.com', + so on act/gitea (which has no Deployments API) the steps are skipped and the + workflow stays runnable. Live Deployments API assertions are therefore out of + scope for this scenario; it proves the wiring is present, schema-valid, and + byte-stable. This scenario declares the toggle and the environment_url, + generates the workflows, then regenerates and proves the output is + byte-identical with no drift. + +config: + trunk_branch: main + environments: [production] + deployments: + enabled: true + keep_prior_active: true + environment_config: + production: + environment_url: "https://app.example.com" + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Seed a minimal source tree" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + + - name: "Regenerate and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/internal/config/native_deployments_test.go b/internal/config/native_deployments_test.go new file mode 100644 index 0000000..4b0bd06 --- /dev/null +++ b/internal/config/native_deployments_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +// TestParseDeployments proves the opt-in deployments block and the per-env +// environment_url field parse from a manifest. +func TestParseDeployments(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [production] +deployments: + enabled: true + keep_prior_active: true +environment_config: + production: + environment_url: "https://app.example.com" +`) + if cfg.Deployments == nil || !cfg.Deployments.Enabled || !cfg.Deployments.KeepPriorActive { + t.Fatalf("deployments: %#v", cfg.Deployments) + } + ec, ok := cfg.EnvironmentConfig["production"] + if !ok { + t.Fatalf("environment_config missing production: %#v", cfg.EnvironmentConfig) + } + if ec.EnvironmentURL != "https://app.example.com" { + t.Fatalf("environment_url: %q", ec.EnvironmentURL) + } +} + +// TestDeploymentsValidatesAtCurrentSchemaVersion proves the deployments toggle +// and environment_url are additive: a manifest that sets them validates cleanly +// at CurrentSchemaVersion, confirming the schema version was not bumped. +func TestDeploymentsValidatesAtCurrentSchemaVersion(t *testing.T) { + cfg := &TrunkConfig{ + SchemaVersion: CurrentSchemaVersion, + TrunkBranch: "main", + Environments: []string{"production"}, + Deployments: &DeploymentsConfig{Enabled: true, KeepPriorActive: true}, + EnvironmentConfig: map[string]EnvironmentConfig{ + "production": {EnvironmentURL: "https://app.example.com"}, + }, + } + for _, e := range Validate(cfg) { + t.Fatalf("unexpected validation error for deployments at current schema version: %s", e) + } +} diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 46dd286..679ec96 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -276,6 +276,18 @@ type DriftCheckConfig struct { Comment bool `yaml:"comment,omitempty" json:"comment,omitempty"` } +// DeploymentsConfig configures opt-in GitHub Deployments API integration. +// When enabled, the finalize job creates a Deployment per target environment +// and reports in_progress then success/failure status after each deploy. +type DeploymentsConfig struct { + // Enabled activates GitHub Deployments API reporting in the finalize job. + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + // KeepPriorActive prevents GitHub from auto-inactivating prior deployments + // for the same environment. Maps to auto_inactive:false in the Deployments API. + // Default false relies on GitHub native auto-inactivation. + KeepPriorActive bool `yaml:"keep_prior_active,omitempty" json:"keep_prior_active,omitempty"` +} + // Pin mode constants. const ( PinModeTag = "tag" @@ -365,6 +377,8 @@ type EnvironmentConfig struct { // environment. Names only, never values; the operator creates them out of // band. Variables []string `yaml:"variables,omitempty" json:"variables,omitempty"` + // EnvironmentURL is the URL of the deployed environment, reported to GitHub Deployments API status updates. + EnvironmentURL string `yaml:"environment_url,omitempty" json:"environment_url,omitempty"` } // Environment branch-policy mode constants. They map onto GitHub's diff --git a/internal/config/types.go b/internal/config/types.go index 12b5371..bcd0e61 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -82,7 +82,7 @@ type DeployState struct { Version string `yaml:"version,omitempty" json:"version,omitempty"` // Version this deployable deployed (reserved-shape; independent of env-level Version) DeployedAt string `yaml:"deployed_at,omitempty" json:"deployed_at,omitempty"` DeployedBy string `yaml:"deployed_by,omitempty" json:"deployed_by,omitempty"` - Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"` // state_tags values + Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"` // state_tags values TargetSHA string `yaml:"target_sha,omitempty" json:"target_sha,omitempty"` // RESERVED - reconciled GitOps-repo HEAD SHA, so a future implementation can key promotion off it. Not yet wired. } @@ -114,14 +114,14 @@ const ( // TrunkConfig represents the pipeline configuration (within config: section) type TrunkConfig struct { - SchemaVersion int `yaml:"schema_version,omitempty" json:"schema_version,omitempty"` // Manifest schema generation (default: CurrentSchemaVersion when omitted) - TrunkBranch string `yaml:"trunk_branch" json:"trunk_branch"` - Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // Global triggers for orchestration workflow paths filter - Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` // Empty = no-environment setup (library/CLI projects) - CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) - TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") - ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN") - StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN") + SchemaVersion int `yaml:"schema_version,omitempty" json:"schema_version,omitempty"` // Manifest schema generation (default: CurrentSchemaVersion when omitted) + TrunkBranch string `yaml:"trunk_branch" json:"trunk_branch"` + Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // Global triggers for orchestration workflow paths filter + Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` // Empty = no-environment setup (library/CLI projects) + CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) + TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") + ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN") + StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN") // ReleaseTokenApp optionally backs the release-token seam with a GitHub App // identity instead of a static secret. When set, the generator mints a // short-lived installation token at run time and the release steps consume it. @@ -129,7 +129,7 @@ type TrunkConfig struct { // StateTokenApp optionally backs the state-token seam with a GitHub App // identity instead of a static secret. When set, the generator mints a // short-lived installation token at run time and the state-write steps consume it. - StateTokenApp *AppTokenSource `yaml:"state_token_app,omitempty" json:"state_token_app,omitempty"` + StateTokenApp *AppTokenSource `yaml:"state_token_app,omitempty" json:"state_token_app,omitempty"` ManifestFile string `yaml:"manifest_file,omitempty" json:"manifest_file,omitempty"` // Config file path (default: ".github/manifest.yaml") ManifestKey string `yaml:"manifest_key,omitempty" json:"manifest_key,omitempty"` // Nested key in manifest file (default: "ci") ActionFolder string `yaml:"action_folder,omitempty" json:"action_folder,omitempty"` // Folder name for manage-release action (default: "manage-release") @@ -145,14 +145,16 @@ type TrunkConfig struct { Concurrency *ConcurrencyConfig `yaml:"concurrency,omitempty" json:"concurrency,omitempty"` // Optional: top-level concurrency: block on the orchestrate workflow // v1 reserved-shape config-level fields (parse + structural validation only). - RunsOn *RunsOn `yaml:"runs_on,omitempty" json:"runs_on,omitempty"` // Default runner for cascade-owned jobs - JobTimeoutMinutes int `yaml:"job_timeout_minutes,omitempty" json:"job_timeout_minutes,omitempty"` // Default timeout-minutes for cascade-owned jobs - DispatchInputs map[string]DispatchInput `yaml:"dispatch_inputs,omitempty" json:"dispatch_inputs,omitempty"` // Operator-facing manual-run inputs - ExtraTriggers *ExtraTriggers `yaml:"extra_triggers,omitempty" json:"extra_triggers,omitempty"` // Non-push trigger types - PRPreview *PRPreviewConfig `yaml:"pr_preview,omitempty" json:"pr_preview,omitempty"` - ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"` - MergeQueue *MergeQueueConfig `yaml:"merge_queue,omitempty" json:"merge_queue,omitempty"` - DriftCheck *DriftCheckConfig `yaml:"drift_check,omitempty" json:"drift_check,omitempty"` // Opt-in workflow drift-check PR lane (#229) + RunsOn *RunsOn `yaml:"runs_on,omitempty" json:"runs_on,omitempty"` // Default runner for cascade-owned jobs + JobTimeoutMinutes int `yaml:"job_timeout_minutes,omitempty" json:"job_timeout_minutes,omitempty"` // Default timeout-minutes for cascade-owned jobs + DispatchInputs map[string]DispatchInput `yaml:"dispatch_inputs,omitempty" json:"dispatch_inputs,omitempty"` // Operator-facing manual-run inputs + ExtraTriggers *ExtraTriggers `yaml:"extra_triggers,omitempty" json:"extra_triggers,omitempty"` // Non-push trigger types + PRPreview *PRPreviewConfig `yaml:"pr_preview,omitempty" json:"pr_preview,omitempty"` + ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"` + MergeQueue *MergeQueueConfig `yaml:"merge_queue,omitempty" json:"merge_queue,omitempty"` + DriftCheck *DriftCheckConfig `yaml:"drift_check,omitempty" json:"drift_check,omitempty"` // Opt-in workflow drift-check PR lane (#229) + // Deployments configures opt-in GitHub Deployments API integration. + Deployments *DeploymentsConfig `yaml:"deployments,omitempty" json:"deployments,omitempty"` PinMode string `yaml:"pin_mode,omitempty" json:"pin_mode,omitempty"` // tag | sha (default tag) ActionPins map[string]string `yaml:"action_pins,omitempty" json:"action_pins,omitempty"` Telemetry *TelemetryConfig `yaml:"telemetry,omitempty" json:"telemetry,omitempty"` diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 51023a9..a55bd55 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -732,6 +732,11 @@ func (g *Generator) writePermissions(sb *strings.Builder) { {"contents", "write"}, {"actions", "read"}, } + // Opt-in GitHub Deployments API reporting needs deployments: write so the + // finalize job can create Deployments and post status updates. + if nativeDeploymentsEnabled(g.config) { + base = append(base, [2]string{"deployments", "write"}) + } writeTopLevelPermissions(sb, base) } @@ -1394,10 +1399,48 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) { g.writeNotifyPrimaryStep(sb) } + // Opt-in GitHub Deployments API reporting for the runtime-selected environment. + g.writeNativeDeploymentSteps(sb, sorted) + // Generate failure check step - only fail on callbacks with on_failure: abort g.writeFailureCheckStep(sb, sorted) } +// writeNativeDeploymentSteps wires the GitHub Deployments API lifecycle into the +// orchestrate finalize job. The orchestrate run deploys to a single environment +// selected at run time (github.event.inputs.environment, defaulting to the first +// configured environment), so the Deployment targets that resolved name. The +// terminal status reflects whether every deploy callback succeeded. +func (g *Generator) writeNativeDeploymentSteps(sb *strings.Builder, sorted []string) { + if !nativeDeploymentsEnabled(g.config) { + return + } + + envExpr := "${{ github.event.inputs.environment }}" + if len(g.config.Environments) > 0 { + envExpr = fmt.Sprintf("${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0]) + } + + // Collect deploy job IDs so the terminal status reflects the real deploy + // outcome. The deployment succeeds only when every deploy callback succeeded. + var deployJobs []string + for _, jobID := range sorted { + if g.graph.Nodes[jobID].Type == config.CallbackTypeDeploy { + deployJobs = append(deployJobs, jobID) + } + } + resultExpr := "success" + if len(deployJobs) > 0 { + var conds []string + for _, jobID := range deployJobs { + conds = append(conds, fmt.Sprintf("needs.%s.result == 'success'", jobID)) + } + resultExpr = fmt.Sprintf("${{ (%s) && 'success' || 'failure' }}", strings.Join(conds, " && ")) + } + + writeNativeDeploymentSteps(sb, g.config, envExpr, resultExpr, " ") +} + func (g *Generator) writeSummaryStep(sb *strings.Builder, sorted []string) { sb.WriteString(" - name: Generate Summary\n") sb.WriteString(" run: |\n") diff --git a/internal/generate/native_deployments.go b/internal/generate/native_deployments.go new file mode 100644 index 0000000..ad564b1 --- /dev/null +++ b/internal/generate/native_deployments.go @@ -0,0 +1,117 @@ +package generate + +import ( + "fmt" + "sort" + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// nativeDeploymentsEnabled reports whether the manifest opted in to GitHub +// Deployments API reporting in the finalize job. +func nativeDeploymentsEnabled(cfg *config.TrunkConfig) bool { + return cfg.Deployments != nil && cfg.Deployments.Enabled +} + +// deploymentAutoInactive returns the auto_inactive value sent to the Deployments +// API. KeepPriorActive maps to auto_inactive:false so GitHub leaves prior +// deployments for the same environment Active; the default relies on GitHub's +// native auto-inactivation (auto_inactive:true). +func deploymentAutoInactive(cfg *config.TrunkConfig) bool { + if cfg.Deployments != nil && cfg.Deployments.KeepPriorActive { + return false + } + return true +} + +// writeNativeDeploymentSteps emits the GitHub Deployments API lifecycle for the +// finalize job: create a Deployment for the runtime-selected environment, mark +// it in_progress, then report a terminal success/failure status. Every step is +// guarded to real GitHub via appTokenServerGuard so the workflow stays runnable +// on act/gitea, where the Deployments API is absent. +// +// envExpr is the shell-safe expression that resolves to the target environment +// name at run time (it differs between the orchestrate and promote seams). +// resultExpr is the shell expression that evaluates to "success" or "failure" +// for the deploy outcome. indent is the per-step indent (matching the +// surrounding generated YAML). +func writeNativeDeploymentSteps(sb *strings.Builder, cfg *config.TrunkConfig, envExpr, resultExpr, indent string) { + if !nativeDeploymentsEnabled(cfg) { + return + } + + body := indent + " " + + // Create the Deployment. The environment name is resolved at run time, so the + // URL lookup is a shell case over the configured environment_config entries. + sb.WriteString(indent + "- name: Create deployment\n") + sb.WriteString(body + "id: cascade-deployment\n") + sb.WriteString(body + "if: " + appTokenServerGuard + "\n") + sb.WriteString(body + "env:\n") + sb.WriteString(body + " GH_TOKEN: ${{ github.token }}\n") + sb.WriteString(body + "run: |\n") + fmt.Fprintf(sb, "%s ENV_NAME=\"%s\"\n", body, envExpr) + fmt.Fprintf(sb, "%s deployment_id=$(gh api repos/${{ github.repository }}/deployments \\\n", body) + sb.WriteString(body + " --method POST \\\n") + sb.WriteString(body + " --field ref=${{ github.sha }} \\\n") + sb.WriteString(body + " --field environment=\"$ENV_NAME\" \\\n") + sb.WriteString(body + " --field auto_merge=false \\\n") + sb.WriteString(body + " --raw-field required_contexts='[]' \\\n") + fmt.Fprintf(sb, "%s --field auto_inactive=%t \\\n", body, deploymentAutoInactive(cfg)) + sb.WriteString(body + " --jq '.id')\n") + sb.WriteString(body + " echo \"deployment_id=${deployment_id}\" >> \"$GITHUB_OUTPUT\"\n") + + // Mark the deployment in_progress. + sb.WriteString(indent + "- name: Set deployment in_progress\n") + sb.WriteString(body + "if: " + appTokenServerGuard + "\n") + sb.WriteString(body + "env:\n") + sb.WriteString(body + " GH_TOKEN: ${{ github.token }}\n") + sb.WriteString(body + "run: |\n") + fmt.Fprintf(sb, "%s gh api repos/${{ github.repository }}/deployments/${{ steps.cascade-deployment.outputs.deployment_id }}/statuses \\\n", body) + sb.WriteString(body + " --method POST \\\n") + sb.WriteString(body + " --field state=in_progress\n") + + // Report the terminal status. always() lets it run even when a deploy failed, + // so the Deployment never sticks at in_progress. The URL is selected by the + // runtime environment name from the configured environment_config entries. + sb.WriteString(indent + "- name: Set deployment status\n") + sb.WriteString(body + "if: ${{ " + stripTokenExprWrapper(appTokenServerGuard) + " && always() }}\n") + sb.WriteString(body + "env:\n") + sb.WriteString(body + " GH_TOKEN: ${{ github.token }}\n") + sb.WriteString(body + "run: |\n") + fmt.Fprintf(sb, "%s ENV_NAME=\"%s\"\n", body, envExpr) + fmt.Fprintf(sb, "%s if [ \"%s\" = \"success\" ]; then state=\"success\"; else state=\"failure\"; fi\n", body, resultExpr) + writeEnvironmentURLCase(sb, cfg, body) + fmt.Fprintf(sb, "%s gh api repos/${{ github.repository }}/deployments/${{ steps.cascade-deployment.outputs.deployment_id }}/statuses \\\n", body) + sb.WriteString(body + " --method POST \\\n") + sb.WriteString(body + " --field state=\"$state\" \\\n") + sb.WriteString(body + " --field environment_url=\"$environment_url\"\n") +} + +// writeEnvironmentURLCase emits a shell case that sets $environment_url from the +// per-environment environment_config..environment_url entries, keyed on the +// runtime $ENV_NAME. Environments without a configured URL fall through to the +// 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 + } + } + sb.WriteString(body + " environment_url=\"\"\n") + if len(urls) == 0 { + return + } + names := make([]string, 0, len(urls)) + for name := range urls { + names = append(names, name) + } + sort.Strings(names) + sb.WriteString(body + " case \"$ENV_NAME\" in\n") + for _, name := range names { + fmt.Fprintf(sb, "%s %s) environment_url=\"%s\" ;;\n", body, name, urls[name]) + } + sb.WriteString(body + " esac\n") +} diff --git a/internal/generate/native_deployments_test.go b/internal/generate/native_deployments_test.go new file mode 100644 index 0000000..a51f50e --- /dev/null +++ b/internal/generate/native_deployments_test.go @@ -0,0 +1,134 @@ +package generate + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +// nativeDeploymentsConfig returns a minimal manifest with the GitHub Deployments +// API integration enabled and a per-environment environment_url, plus the +// build/deploy callbacks and on-disk deploy workflow the generator needs so the +// finalize job can reference a real deploy job result. +func nativeDeploymentsConfig(t *testing.T) (*config.TrunkConfig, string) { + t.Helper() + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0o755)) + + deployWorkflow := `name: Deploy +on: + workflow_call: + inputs: + environment: + required: true + type: string +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/deploy.yaml"), []byte(deployWorkflow), 0o644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"production"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + Deployments: &config.DeploymentsConfig{Enabled: true}, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "production": {EnvironmentURL: "https://app.example.com"}, + }, + } + return cfg, tmpDir +} + +// TestNativeDeployments_Enabled proves the finalize job creates a GitHub +// Deployment, reports in_progress, then reports a terminal status, all guarded +// to real GitHub, with deployments: write scope and the environment_url wired in. +func TestNativeDeployments_Enabled(t *testing.T) { + cfg, tmpDir := nativeDeploymentsConfig(t) + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + assert.Contains(t, out, "deployments: write", + "finalize must request deployments: write when deployments is enabled") + assert.Contains(t, out, "/deployments \\", + "must POST to the deployments collection to create a Deployment") + assert.Contains(t, out, "state=in_progress", + "must report an in_progress deployment status") + assert.Contains(t, out, "/statuses \\", + "must POST to the deployment statuses collection") + assert.Contains(t, out, "https://app.example.com", + "the configured environment_url must be wired into the status update") + assert.Contains(t, out, appTokenServerGuard, + "deployment steps must be guarded to real GitHub via the server_url guard") + // The terminal status reflects the deploy job result, not a hardcoded value. + assert.Contains(t, out, "needs.deploy-app.result", + "terminal status must derive from the deploy job result") +} + +// TestNativeDeployments_Disabled proves none of the Deployments API wiring is +// emitted when the toggle is absent, keeping the OFF-state output unchanged. +func TestNativeDeployments_Disabled(t *testing.T) { + cfg, tmpDir := nativeDeploymentsConfig(t) + cfg.Deployments = nil + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + for _, marker := range []string{ + "deployments: write", + "/deployments \\", + "state=in_progress", + } { + assert.NotContains(t, out, marker, + "disabled deployments must not emit %q", marker) + } +} + +// TestNativeDeployments_Deterministic proves byte-stability across repeated +// generation, which the verify/no-drift contract depends on. +func TestNativeDeployments_Deterministic(t *testing.T) { + cfg, tmpDir := nativeDeploymentsConfig(t) + + first, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + second, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + assert.Equal(t, first, second) +} + +// TestNativeDeployments_Actionlint runs actionlint over the generated workflow. +// Skipped when actionlint is not installed so the suite stays hermetic. +func TestNativeDeployments_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + cfg, tmpDir := nativeDeploymentsConfig(t) + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0o755)) + wfPath := filepath.Join(wfDir, "orchestrate.yaml") + require.NoError(t, os.WriteFile(wfPath, []byte(out), 0o644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + writeDeployReusableStub(t, dir, out) + + // Disable shellcheck: inline run: bodies trip style nits orthogonal to the + // Deployments API step structure this test governs. + cmd := exec.Command(bin, "-shellcheck=", wfPath) + cmd.Dir = dir + output, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(output)) +} diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 1e732f4..a4dd7c5 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -606,6 +606,11 @@ func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) { {"contents", "write"}, {"actions", "write"}, } + // Opt-in GitHub Deployments API reporting needs deployments: write so the + // finalize job can create Deployments and post status updates. + if nativeDeploymentsEnabled(g.config) { + base = append(base, [2]string{"deployments", "write"}) + } writeTopLevelPermissions(sb, base) } @@ -1303,6 +1308,9 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" --run-id \"${{ github.run_id }}\" \\\n") sb.WriteString(" --commit-push\n") + // Opt-in GitHub Deployments API reporting for the promotion target environment. + g.writeNativeDeploymentSteps(sb) + // Summary sb.WriteString(" - name: Summary\n") sb.WriteString(" env:\n") @@ -1329,6 +1337,29 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" } >> \"$GITHUB_STEP_SUMMARY\"\n") } +// writeNativeDeploymentSteps wires the GitHub Deployments API lifecycle into the +// promote finalize job. A promotion targets a single environment resolved at run +// time (needs.preflight.outputs.target_env), so the Deployment targets that +// name. The terminal status reflects whether every deploy job succeeded. +func (g *PromoteGenerator) writeNativeDeploymentSteps(sb *strings.Builder) { + if !nativeDeploymentsEnabled(g.config) { + return + } + + envExpr := "${{ needs.preflight.outputs.target_env }}" + + resultExpr := "success" + if len(g.config.Environments) > 0 && len(g.config.Deploys) > 0 { + var conds []string + for _, d := range g.config.Deploys { + conds = append(conds, fmt.Sprintf("needs.deploy-%s.result == 'success'", d.Name)) + } + resultExpr = fmt.Sprintf("${{ (%s) && 'success' || 'failure' }}", strings.Join(conds, " && ")) + } + + writeNativeDeploymentSteps(sb, g.config, envExpr, resultExpr, " ") +} + // writeConcurrency emits a top-level concurrency: block on the promote workflow. // Every promote finalize pushes the same shared .github/manifest.yaml (env state) // and writes shared release tags, so ANY two concurrent promote runs race on those diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 23a16d6..4c6693d 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -151,6 +151,7 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, "drift_check": { "$ref": "#/definitions/driftCheckConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, "pin_mode": { "type": "string", "enum": ["tag", "sha"], @@ -667,6 +668,15 @@ "comment": { "type": "boolean" } } }, + "deploymentsConfig": { + "type": "object", + "additionalProperties": false, + "description": "Opt-in GitHub Deployments API integration. When enabled, the finalize job creates a Deployment per target environment and reports in_progress then success/failure status after each deploy.", + "properties": { + "enabled": { "type": "boolean", "description": "Activates GitHub Deployments API reporting in the finalize job." }, + "keep_prior_active": { "type": "boolean", "description": "Prevents GitHub from auto-inactivating prior deployments for the same environment (auto_inactive:false). Default false relies on native auto-inactivation." } + } + }, "telemetryConfig": { "type": "object", "additionalProperties": false, @@ -731,6 +741,10 @@ "type": "array", "items": { "type": "string" }, "description": "Expected env-scoped variable NAMES (names only, never values)." + }, + "environment_url": { + "type": "string", + "description": "URL of the deployed environment, reported to GitHub Deployments API status updates." } } }, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 23a16d6..4c6693d 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -151,6 +151,7 @@ "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, "drift_check": { "$ref": "#/definitions/driftCheckConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, "pin_mode": { "type": "string", "enum": ["tag", "sha"], @@ -667,6 +668,15 @@ "comment": { "type": "boolean" } } }, + "deploymentsConfig": { + "type": "object", + "additionalProperties": false, + "description": "Opt-in GitHub Deployments API integration. When enabled, the finalize job creates a Deployment per target environment and reports in_progress then success/failure status after each deploy.", + "properties": { + "enabled": { "type": "boolean", "description": "Activates GitHub Deployments API reporting in the finalize job." }, + "keep_prior_active": { "type": "boolean", "description": "Prevents GitHub from auto-inactivating prior deployments for the same environment (auto_inactive:false). Default false relies on native auto-inactivation." } + } + }, "telemetryConfig": { "type": "object", "additionalProperties": false, @@ -731,6 +741,10 @@ "type": "array", "items": { "type": "string" }, "description": "Expected env-scoped variable NAMES (names only, never values)." + }, + "environment_url": { + "type": "string", + "description": "URL of the deployed environment, reported to GitHub Deployments API status updates." } } }, From 7f91325325321827f092ffff66e17efcbbb1c697 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 21 Jun 2026 07:55:06 -0400 Subject: [PATCH 2/2] test: assert native deployment steps in e2e scenario and wire harness config Signed-off-by: Joshua Temple --- e2e/harness/scenario.go | 5 +++++ e2e/scenarios/31-native-deployments.yaml | 25 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index 0827465..d82de7a 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -79,6 +79,11 @@ type Config struct { // the generated manifest untouched, so a scenario can enable the generated PR // drift-check workflow and its fork-safe comment companion (#229). DriftCheck map[string]any `yaml:"drift_check,omitempty"` + // Deployments carries the opt-in native GitHub Deployments block (enabled, + // keep_prior_active) through to the generated manifest untouched, so a + // scenario can enable the finalize-seam Deployments API steps. A generic map + // keeps the harness decoupled from the generator's DeploymentsConfig shape. + Deployments map[string]any `yaml:"deployments,omitempty"` Notify map[string]any `yaml:"notify,omitempty"` External []map[string]any `yaml:"external,omitempty"` // Telemetry carries the reserved vendor-neutral telemetry block (enabled, diff --git a/e2e/scenarios/31-native-deployments.yaml b/e2e/scenarios/31-native-deployments.yaml index fc129ef..11dc894 100644 --- a/e2e/scenarios/31-native-deployments.yaml +++ b/e2e/scenarios/31-native-deployments.yaml @@ -10,10 +10,11 @@ description: | Each Deployments API step is guarded by github.server_url == 'https://github.com', so on act/gitea (which has no Deployments API) the steps are skipped and the workflow stays runnable. Live Deployments API assertions are therefore out of - scope for this scenario; it proves the wiring is present, schema-valid, and - byte-stable. This scenario declares the toggle and the environment_url, - generates the workflows, then regenerates and proves the output is - byte-identical with no drift. + scope for this scenario; it proves the emitted steps, the deployments: write + permission, the environment_url wiring, and the server_url guard are present, + schema-valid, and byte-stable. The commit step generates the workflows and + asserts the deployment steps are emitted and guarded; the verify step then + regenerates and proves the output is byte-identical with no drift. config: trunk_branch: main @@ -34,7 +35,7 @@ config: triggers: ["src/**"] steps: - - name: "Seed a minimal source tree" + - name: "Seed a minimal source tree; assert deployment steps are emitted and guarded" action: commit commit: message: "seed source" @@ -43,6 +44,20 @@ steps: package main func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + - " deployments: write" + - " - name: Create deployment" + - " if: ${{ github.server_url == 'https://github.com' }}" + - " deployment_id=$(gh api repos/${{ github.repository }}/deployments \\" + - " --field auto_inactive=false \\" + - " - name: Set deployment in_progress" + - " --field state=in_progress" + - " - name: Set deployment status" + - " if: ${{ github.server_url == 'https://github.com' && always() }}" + - " production) environment_url=\"https://app.example.com\" ;;" - name: "Regenerate and confirm no drift" action: verify