From e915d53a87fc05067a0fd40bfc5d693cc995d87d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 14:37:26 -0400 Subject: [PATCH] feat: add hand-authored manifest JSON schema for editor validation Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + docs/public/manifest.schema.json | 622 +++++++++++++++++++++++++ docs/src/content/docs/adoption.md | 2 + docs/src/content/docs/cli-reference.md | 20 + docs/src/content/docs/configuration.md | 37 ++ go.mod | 2 + go.sum | 4 + internal/schema/command.go | 51 ++ internal/schema/manifest.schema.json | 622 +++++++++++++++++++++++++ internal/schema/schema.go | 32 ++ internal/schema/schema_test.go | 309 ++++++++++++ schema/manifest.schema.json | 622 +++++++++++++++++++++++++ 12 files changed, 2325 insertions(+) create mode 100644 docs/public/manifest.schema.json create mode 100644 internal/schema/command.go create mode 100644 internal/schema/manifest.schema.json create mode 100644 internal/schema/schema.go create mode 100644 internal/schema/schema_test.go create mode 100644 schema/manifest.schema.json diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 5789968..8edc0d1 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -19,6 +19,7 @@ import ( "github.com/stablekernel/cascade/internal/release" "github.com/stablekernel/cascade/internal/reset" "github.com/stablekernel/cascade/internal/rollback" + "github.com/stablekernel/cascade/internal/schema" "github.com/stablekernel/cascade/internal/status" versionpkg "github.com/stablekernel/cascade/internal/version" ) @@ -76,6 +77,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(release.NewCommand()) rootCmd.AddCommand(reset.NewCommand()) rootCmd.AddCommand(rollback.NewCommand()) + rootCmd.AddCommand(schema.NewCommand()) rootCmd.AddCommand(status.NewCommand()) rootCmd.AddCommand(versionpkg.NewCommand()) rootCmd.AddCommand(newVersionCmd()) diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json new file mode 100644 index 0000000..2202d6b --- /dev/null +++ b/docs/public/manifest.schema.json @@ -0,0 +1,622 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://stablekernel.github.io/cascade/manifest.schema.json", + "title": "cascade manifest", + "description": "Structure, types, and enums for a cascade manifest file. The manifest is nested under a top key (default \"ci\", configurable via config.manifest_key) and carries the authoring surface (config), plus cascade-managed promotion state (state, latest_release). This schema powers editor autocomplete and hover docs; cascade parse-config remains the authority for semantic and cross-field rules.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional pointer to this JSON Schema for editor validation." + }, + "ci": { + "$ref": "#/definitions/manifestBody", + "description": "The cascade manifest body. The key name is configurable via config.manifest_key (default \"ci\")." + } + }, + "definitions": { + "manifestBody": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/definitions/trunkConfig", + "description": "The pipeline configuration: the authoring surface for cascade." + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed promotion state keyed by environment name. Authored by cascade, not by hand; modeled permissively so real manifests validate." + }, + "latest_release": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed record of the most recent published release. Authored by cascade, not by hand." + } + } + }, + "trunkConfig": { + "type": "object", + "additionalProperties": false, + "required": ["trunk_branch"], + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Manifest schema generation. Defaults to the current generation when omitted. A single monotonic integer, not a semver string." + }, + "trunk_branch": { + "type": "string", + "description": "The trunk branch that cascade orchestrates from (for example \"main\")." + }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." + }, + "environments": { + "type": "array", + "items": { "type": "string" }, + "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + }, + "cli_version": { + "type": "string", + "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." + }, + "tag_prefix": { + "type": "string", + "description": "Version tag prefix (default: \"v\")." + }, + "release_token": { + "type": "string", + "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "state_token": { + "type": "string", + "description": "GitHub Actions secret expression for writing manifest state back to the trunk branch (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "manifest_file": { + "type": "string", + "description": "Path to the manifest file (default: \".github/manifest.yaml\")." + }, + "manifest_key": { + "type": "string", + "description": "Nested key in the manifest file that holds the body (default: \"ci\")." + }, + "action_folder": { + "type": "string", + "description": "Folder name for the manage-release action (default: \"manage-release\")." + }, + "git": { "$ref": "#/definitions/gitConfig" }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { + "type": "array", + "items": { "$ref": "#/definitions/buildConfig" }, + "description": "Build callbacks. Empty means no orchestrated builds." + }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/deployConfig" }, + "description": "Deploy callbacks. Empty means no deploys (library or CLI projects)." + }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { + "type": "array", + "items": { "$ref": "#/definitions/externalRepoConfig" }, + "description": "External repositories this primary coordinates." + }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { + "$ref": "#/definitions/runsOn", + "description": "Default runner for cascade-owned jobs." + }, + "job_timeout_minutes": { + "type": "integer", + "description": "Default timeout-minutes for cascade-owned jobs." + }, + "dispatch_inputs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, + "description": "Operator-facing manual-run inputs." + }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, + "pin_mode": { + "type": "string", + "enum": ["tag", "sha"], + "description": "How action references are pinned (default: tag)." + }, + "action_pins": { + "type": "object", + "additionalProperties": { "type": "string" }, + "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." + } + } + }, + "secrets": { + "description": "Secrets passing for a callback. Either the scalar \"inherit\" (pass all caller secrets), the mapping {inherit: true}, or an explicit map of called-workflow secret name to caller secret name (least-privilege). inherit cannot be mixed with explicit keys.", + "oneOf": [ + { "type": "string", "const": "inherit" }, + { + "type": "object", + "additionalProperties": false, + "required": ["inherit"], + "properties": { "inherit": { "type": "boolean" } } + }, + { + "type": "object", + "additionalProperties": { "type": "string" }, + "not": { "required": ["inherit"] } + } + ] + }, + "runsOn": { + "description": "Runner selection. A single label, a list of labels, or a {group, labels} object (at least one of group or labels).", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "group": { "type": "string", "description": "Runner group name." }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Runner labels." + } + }, + "anyOf": [ + { "required": ["group"] }, + { "required": ["labels"] } + ] + } + ] + }, + "concurrencyConfig": { + "type": "object", + "additionalProperties": false, + "description": "Concurrency control for the generated orchestrate workflow.", + "properties": { + "group": { + "type": "string", + "description": "Concurrency group expression (default: orchestrate-${{ github.ref }})." + }, + "cancel_in_progress": { + "type": "boolean", + "description": "Cancel older in-progress runs (default: true). Set false to queue runs instead." + } + } + }, + "permissions": { + "type": "object", + "description": "GITHUB_TOKEN permission scopes mapped to access levels.", + "additionalProperties": { + "type": "string", + "enum": ["read", "write", "none"] + } + }, + "inputs": { + "type": "object", + "additionalProperties": true, + "description": "Freeform inputs passed to the callback workflow." + }, + "envInputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "description": "Per-environment inputs: a map of environment name to a freeform inputs map." + }, + "gitConfig": { + "type": "object", + "additionalProperties": false, + "description": "Git identity and signing configuration.", + "properties": { + "mode": { + "type": "string", + "enum": ["default", "custom", "external"], + "description": "Git identity mode: default (github-actions bot), custom (use user_name/user_email), or external (pre-configured)." + }, + "user_name": { "type": "string", "description": "git user.name (when mode is custom)." }, + "user_email": { "type": "string", "description": "git user.email (when mode is custom)." }, + "gpg_key_id": { "type": "string", "description": "GPG key ID for signing." }, + "gpg_key_secret": { "type": "string", "description": "Secret name containing the GPG key." } + } + }, + "validateConfig": { + "type": "object", + "additionalProperties": false, + "description": "The singleton validation callback.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable validation workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns that should trigger validation." + }, + "supports_dry_run": { "type": "boolean" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" } + } + }, + "buildConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A build callback.", + "properties": { + "name": { "type": "string", "description": "Build identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable build workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/definitions/artifactConfig" } + }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "matrix": { "$ref": "#/definitions/matrixConfig" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "deployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deploy callback.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable deploy workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "supports_dry_run": { "type": "boolean" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "artifactConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name", "path"], + "description": "A release artifact produced by a build.", + "properties": { + "name": { "type": "string", "description": "Artifact identifier (for example \"linux-amd64\")." }, + "path": { "type": "string", "description": "Glob pattern for files to include (for example \"dist/*.tar.gz\")." }, + "required": { "type": "boolean", "description": "Fail the release if the artifact is missing (default: true)." } + } + }, + "passthroughArtifact": { + "type": "object", + "additionalProperties": false, + "description": "GitHub Actions artifact passing between jobs within a single orchestrate run.", + "properties": { + "upload": { "type": "string", "description": "Path glob to upload after this job completes." }, + "downloads": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of upstream build jobs whose artifacts to download first." + } + } + }, + "matrixConfig": { + "type": "object", + "additionalProperties": false, + "description": "Build fan-out via a matrix. Builds only.", + "properties": { + "dimensions": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + }, + "description": "Cross-product axes (for example os, arch)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent matrix legs (0 means the platform default)." }, + "fail_fast": { "type": "boolean" } + } + }, + "rolloutConfig": { + "type": "object", + "additionalProperties": false, + "description": "Deploy rollout strategy. Deploys only.", + "properties": { + "type": { + "type": "string", + "enum": ["default", "rolling", "canary", "blue_green"], + "description": "Rollout strategy (default: default)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent rollout waves." }, + "fail_fast": { "type": "boolean" }, + "canary": { "$ref": "#/definitions/canaryConfig" }, + "blue_green": { "$ref": "#/definitions/blueGreenConfig" } + } + }, + "canaryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Canary rollout sub-block, used when rollout type is canary.", + "properties": { + "steps": { + "type": "array", + "items": { "type": "integer" }, + "description": "Percent waves (for example [10, 50, 100])." + }, + "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + } + }, + "blueGreenConfig": { + "type": "object", + "additionalProperties": false, + "description": "Blue/green rollout sub-block, used when rollout type is blue_green.", + "properties": { + "switch": { "type": "string", "description": "Workflow path that performs the blue/green cutover." } + } + }, + "deployTarget": { + "type": "object", + "additionalProperties": false, + "description": "GitOps-mirror deploy variant.", + "properties": { + "mode": { + "type": "string", + "enum": ["dispatch", "gitops"], + "description": "Target mode (default: dispatch via external/notify)." + }, + "repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." }, + "path": { "type": "string", "description": "File to mutate in the target repo." }, + "field": { "type": "string", "description": "Field to bump (for example image.tag)." }, + "value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." } + } + }, + "publishConfig": { + "type": "object", + "additionalProperties": false, + "required": ["workflow"], + "description": "Publish callback invoked after a release is published.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable publish workflow." } + } + }, + "releaseConfig": { + "type": "object", + "additionalProperties": false, + "description": "Release management settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable cascade-managed releases." }, + "tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." } + } + }, + "changelogConfig": { + "type": "object", + "additionalProperties": false, + "description": "Changelog generation settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable changelog generation." }, + "workflow": { "type": "string", "description": "Custom changelog workflow." }, + "contributors": { "type": "boolean", "description": "Include a contributors section." } + } + }, + "notifyConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo"], + "description": "How a satellite repo notifies its primary after dev deploys.", + "properties": { + "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, + "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + } + }, + "externalRepoConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "deploys"], + "description": "An external repository this primary coordinates.", + "properties": { + "repo": { "type": "string", "description": "External repo (for example org/cdk-infra)." }, + "ref": { "type": "string", "description": "Branch or tag reference (default: trunk_branch)." }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/externalDeployConfig" }, + "description": "Deployables from this repo." + } + } + }, + "externalDeployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deployable from an external repository.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Workflow path, local or cross-repo (org/repo/.github/...@ref)." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" } + } + }, + "dispatchInput": { + "type": "object", + "additionalProperties": false, + "description": "A single operator-facing workflow_dispatch input.", + "properties": { + "type": { + "type": "string", + "enum": ["string", "boolean", "choice", "environment", "number"], + "description": "Input type." + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Valid values for a choice input." + }, + "default": { "description": "Default value (any supported type)." }, + "description": { "type": "string", "description": "Operator-facing help text." }, + "required": { "type": "boolean", "description": "Mark the input as required." } + } + }, + "extraTriggers": { + "type": "object", + "additionalProperties": false, + "description": "Config-level non-push trigger types.", + "properties": { + "schedule": { + "type": "array", + "items": { "$ref": "#/definitions/scheduleEntry" }, + "description": "Cron schedule entries." + }, + "repository_dispatch": { + "type": "object", + "additionalProperties": false, + "description": "Enables the repository_dispatch trigger.", + "properties": { + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "workflow_run": { + "type": "object", + "additionalProperties": false, + "description": "Enables the workflow_run trigger.", + "properties": { + "workflows": { "type": "array", "items": { "type": "string" } }, + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "merge_group": { + "type": "object", + "additionalProperties": false, + "description": "When present (even empty), wires the merge-queue trigger." + } + } + }, + "scheduleEntry": { + "type": "object", + "additionalProperties": false, + "required": ["cron"], + "description": "A single cron schedule.", + "properties": { + "cron": { "type": "string", "description": "Cron expression." } + } + }, + "prPreviewConfig": { + "type": "object", + "additionalProperties": false, + "description": "Read-only PR plan-preview lane.", + "properties": { + "enabled": { "type": "boolean" }, + "comment": { "type": "boolean" } + } + }, + "validateCheckConfig": { + "type": "object", + "additionalProperties": false, + "description": "Manifest-validation-as-a-PR-check lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "mergeQueueConfig": { + "type": "object", + "additionalProperties": false, + "description": "Merge-queue validation lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "telemetryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Vendor-neutral metrics seam.", + "properties": { + "enabled": { "type": "boolean" }, + "adapter": { "type": "string", "description": "Metrics adapter (for example none, datadog)." } + } + }, + "environmentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Per-environment settings block.", + "properties": { + "gha_environment": { + "type": "string", + "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)." + } + } + }, + "runPolicy": { + "type": "string", + "enum": ["default", "always", "force"], + "description": "Run policy for the callback." + }, + "onFailure": { + "type": "string", + "enum": ["abort", "continue"], + "description": "Behavior when the callback fails." + } + } +} diff --git a/docs/src/content/docs/adoption.md b/docs/src/content/docs/adoption.md index 8ef8bef..684f5a4 100644 --- a/docs/src/content/docs/adoption.md +++ b/docs/src/content/docs/adoption.md @@ -67,6 +67,8 @@ ci: cascade manages `state:` and `latest_release:`; the empty skeleton is enough. See the [Manifest Reference](/cascade/configuration/) for every field. +For autocomplete and inline validation while you edit the manifest, register the JSON Schema with your editor. See [Editor support](/cascade/configuration/#editor-support). + ### 3. Provide the callback workflows Each callback is a reusable workflow with an `on: workflow_call` trigger. cascade passes a fixed set of inputs and reads back any `outputs:` you declare. The exact, full YAML for each lives in the [Callback Contract](/cascade/callback-contract/); the contract below is the summary. diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 6e8a516..1cc5e29 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -544,6 +544,26 @@ cascade reset --state --push Deletes all GitHub releases and tags. With `--state`, also clears the state section. +### schema + +Print the manifest JSON Schema. Point your editor at it for autocomplete, type checking, and hover docs while authoring `.github/manifest.yaml`. See [Editor support](/cascade/configuration/#editor-support) for registration. + +```bash +# Print the schema to stdout +cascade schema + +# Write the schema to a file +cascade schema --output manifest.schema.json +``` + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--output`, `-o` | string | stdout | Write the schema to a file instead of stdout | + +The same schema is published at `https://stablekernel.github.io/cascade/manifest.schema.json`. `parse-config` remains the authority for semantic and cross-field rules; the schema covers structure, types, enums, and hover docs. + ## Environment Variables | Variable | Description | diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index f3bc763..1b0e150 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -29,6 +29,43 @@ ci: The wrapper key (`ci:` by default) is configurable via `config.manifest_key`. The file path is configurable via `config.manifest_file`. +## Editor support + +cascade ships a hand-authored JSON Schema for the manifest. Registering it with your editor gives you autocomplete, type checking, enum hints, and hover documentation while you author `.github/manifest.yaml`. The schema covers structure, types, and enums; `cascade parse-config` remains the authority for semantic and cross-field rules. + +The schema is published at: + +``` +https://stablekernel.github.io/cascade/manifest.schema.json +``` + +You can also print the embedded copy with `cascade schema` (write it to a file with `cascade schema --output manifest.schema.json`). + +### YAML language server directive + +Add this comment to the top of `.github/manifest.yaml`. The YAML language server (used by VS Code, Neovim, and others) reads it automatically: + +```yaml +# yaml-language-server: $schema=https://stablekernel.github.io/cascade/manifest.schema.json +ci: + config: + trunk_branch: main +``` + +### VS Code settings + +Alternatively, map the schema to your manifest path in `settings.json`: + +```json +{ + "yaml.schemas": { + "https://stablekernel.github.io/cascade/manifest.schema.json": ".github/manifest.yaml" + } +} +``` + +If your manifest uses a different path or wrapper key, point the mapping at your file. Either registration path works; the directive travels with the file, while the settings mapping is per-workspace. + ## Config Section ### Top-Level Fields diff --git a/go.mod b/go.mod index cdbc4f8..4f9ed9c 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 5352f4f..9124392 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -13,6 +15,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/schema/command.go b/internal/schema/command.go new file mode 100644 index 0000000..fa825cb --- /dev/null +++ b/internal/schema/command.go @@ -0,0 +1,51 @@ +package schema + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// NewCommand creates the schema command, which prints the embedded manifest +// JSON Schema. Editors can use the schema for autocomplete, type checking, and +// hover documentation while authoring a manifest. +func NewCommand() *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "schema", + Short: "Print the manifest JSON Schema", + Long: `Print the JSON Schema for the cascade manifest. + +The schema describes the structure, types, and allowed values of a manifest +file. Point your editor at it for autocomplete, type checking, and hover docs +while authoring .github/manifest.yaml. cascade parse-config remains the +authority for semantic and cross-field rules. + +Examples: + # Print the schema to stdout + cascade schema + + # Write the schema to a file + cascade schema --output manifest.schema.json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + data := Bytes() + if output != "" { + if err := os.WriteFile(output, data, 0o644); err != nil { + return fmt.Errorf("writing schema to %q: %w", output, err) + } + return nil + } + if _, err := cmd.OutOrStdout().Write(data); err != nil { + return fmt.Errorf("writing schema: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "", "Write the schema to a file instead of stdout") + + return cmd +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json new file mode 100644 index 0000000..2202d6b --- /dev/null +++ b/internal/schema/manifest.schema.json @@ -0,0 +1,622 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://stablekernel.github.io/cascade/manifest.schema.json", + "title": "cascade manifest", + "description": "Structure, types, and enums for a cascade manifest file. The manifest is nested under a top key (default \"ci\", configurable via config.manifest_key) and carries the authoring surface (config), plus cascade-managed promotion state (state, latest_release). This schema powers editor autocomplete and hover docs; cascade parse-config remains the authority for semantic and cross-field rules.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional pointer to this JSON Schema for editor validation." + }, + "ci": { + "$ref": "#/definitions/manifestBody", + "description": "The cascade manifest body. The key name is configurable via config.manifest_key (default \"ci\")." + } + }, + "definitions": { + "manifestBody": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/definitions/trunkConfig", + "description": "The pipeline configuration: the authoring surface for cascade." + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed promotion state keyed by environment name. Authored by cascade, not by hand; modeled permissively so real manifests validate." + }, + "latest_release": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed record of the most recent published release. Authored by cascade, not by hand." + } + } + }, + "trunkConfig": { + "type": "object", + "additionalProperties": false, + "required": ["trunk_branch"], + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Manifest schema generation. Defaults to the current generation when omitted. A single monotonic integer, not a semver string." + }, + "trunk_branch": { + "type": "string", + "description": "The trunk branch that cascade orchestrates from (for example \"main\")." + }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." + }, + "environments": { + "type": "array", + "items": { "type": "string" }, + "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + }, + "cli_version": { + "type": "string", + "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." + }, + "tag_prefix": { + "type": "string", + "description": "Version tag prefix (default: \"v\")." + }, + "release_token": { + "type": "string", + "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "state_token": { + "type": "string", + "description": "GitHub Actions secret expression for writing manifest state back to the trunk branch (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "manifest_file": { + "type": "string", + "description": "Path to the manifest file (default: \".github/manifest.yaml\")." + }, + "manifest_key": { + "type": "string", + "description": "Nested key in the manifest file that holds the body (default: \"ci\")." + }, + "action_folder": { + "type": "string", + "description": "Folder name for the manage-release action (default: \"manage-release\")." + }, + "git": { "$ref": "#/definitions/gitConfig" }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { + "type": "array", + "items": { "$ref": "#/definitions/buildConfig" }, + "description": "Build callbacks. Empty means no orchestrated builds." + }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/deployConfig" }, + "description": "Deploy callbacks. Empty means no deploys (library or CLI projects)." + }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { + "type": "array", + "items": { "$ref": "#/definitions/externalRepoConfig" }, + "description": "External repositories this primary coordinates." + }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { + "$ref": "#/definitions/runsOn", + "description": "Default runner for cascade-owned jobs." + }, + "job_timeout_minutes": { + "type": "integer", + "description": "Default timeout-minutes for cascade-owned jobs." + }, + "dispatch_inputs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, + "description": "Operator-facing manual-run inputs." + }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, + "pin_mode": { + "type": "string", + "enum": ["tag", "sha"], + "description": "How action references are pinned (default: tag)." + }, + "action_pins": { + "type": "object", + "additionalProperties": { "type": "string" }, + "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." + } + } + }, + "secrets": { + "description": "Secrets passing for a callback. Either the scalar \"inherit\" (pass all caller secrets), the mapping {inherit: true}, or an explicit map of called-workflow secret name to caller secret name (least-privilege). inherit cannot be mixed with explicit keys.", + "oneOf": [ + { "type": "string", "const": "inherit" }, + { + "type": "object", + "additionalProperties": false, + "required": ["inherit"], + "properties": { "inherit": { "type": "boolean" } } + }, + { + "type": "object", + "additionalProperties": { "type": "string" }, + "not": { "required": ["inherit"] } + } + ] + }, + "runsOn": { + "description": "Runner selection. A single label, a list of labels, or a {group, labels} object (at least one of group or labels).", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "group": { "type": "string", "description": "Runner group name." }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Runner labels." + } + }, + "anyOf": [ + { "required": ["group"] }, + { "required": ["labels"] } + ] + } + ] + }, + "concurrencyConfig": { + "type": "object", + "additionalProperties": false, + "description": "Concurrency control for the generated orchestrate workflow.", + "properties": { + "group": { + "type": "string", + "description": "Concurrency group expression (default: orchestrate-${{ github.ref }})." + }, + "cancel_in_progress": { + "type": "boolean", + "description": "Cancel older in-progress runs (default: true). Set false to queue runs instead." + } + } + }, + "permissions": { + "type": "object", + "description": "GITHUB_TOKEN permission scopes mapped to access levels.", + "additionalProperties": { + "type": "string", + "enum": ["read", "write", "none"] + } + }, + "inputs": { + "type": "object", + "additionalProperties": true, + "description": "Freeform inputs passed to the callback workflow." + }, + "envInputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "description": "Per-environment inputs: a map of environment name to a freeform inputs map." + }, + "gitConfig": { + "type": "object", + "additionalProperties": false, + "description": "Git identity and signing configuration.", + "properties": { + "mode": { + "type": "string", + "enum": ["default", "custom", "external"], + "description": "Git identity mode: default (github-actions bot), custom (use user_name/user_email), or external (pre-configured)." + }, + "user_name": { "type": "string", "description": "git user.name (when mode is custom)." }, + "user_email": { "type": "string", "description": "git user.email (when mode is custom)." }, + "gpg_key_id": { "type": "string", "description": "GPG key ID for signing." }, + "gpg_key_secret": { "type": "string", "description": "Secret name containing the GPG key." } + } + }, + "validateConfig": { + "type": "object", + "additionalProperties": false, + "description": "The singleton validation callback.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable validation workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns that should trigger validation." + }, + "supports_dry_run": { "type": "boolean" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" } + } + }, + "buildConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A build callback.", + "properties": { + "name": { "type": "string", "description": "Build identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable build workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/definitions/artifactConfig" } + }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "matrix": { "$ref": "#/definitions/matrixConfig" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "deployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deploy callback.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable deploy workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "supports_dry_run": { "type": "boolean" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "artifactConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name", "path"], + "description": "A release artifact produced by a build.", + "properties": { + "name": { "type": "string", "description": "Artifact identifier (for example \"linux-amd64\")." }, + "path": { "type": "string", "description": "Glob pattern for files to include (for example \"dist/*.tar.gz\")." }, + "required": { "type": "boolean", "description": "Fail the release if the artifact is missing (default: true)." } + } + }, + "passthroughArtifact": { + "type": "object", + "additionalProperties": false, + "description": "GitHub Actions artifact passing between jobs within a single orchestrate run.", + "properties": { + "upload": { "type": "string", "description": "Path glob to upload after this job completes." }, + "downloads": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of upstream build jobs whose artifacts to download first." + } + } + }, + "matrixConfig": { + "type": "object", + "additionalProperties": false, + "description": "Build fan-out via a matrix. Builds only.", + "properties": { + "dimensions": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + }, + "description": "Cross-product axes (for example os, arch)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent matrix legs (0 means the platform default)." }, + "fail_fast": { "type": "boolean" } + } + }, + "rolloutConfig": { + "type": "object", + "additionalProperties": false, + "description": "Deploy rollout strategy. Deploys only.", + "properties": { + "type": { + "type": "string", + "enum": ["default", "rolling", "canary", "blue_green"], + "description": "Rollout strategy (default: default)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent rollout waves." }, + "fail_fast": { "type": "boolean" }, + "canary": { "$ref": "#/definitions/canaryConfig" }, + "blue_green": { "$ref": "#/definitions/blueGreenConfig" } + } + }, + "canaryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Canary rollout sub-block, used when rollout type is canary.", + "properties": { + "steps": { + "type": "array", + "items": { "type": "integer" }, + "description": "Percent waves (for example [10, 50, 100])." + }, + "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + } + }, + "blueGreenConfig": { + "type": "object", + "additionalProperties": false, + "description": "Blue/green rollout sub-block, used when rollout type is blue_green.", + "properties": { + "switch": { "type": "string", "description": "Workflow path that performs the blue/green cutover." } + } + }, + "deployTarget": { + "type": "object", + "additionalProperties": false, + "description": "GitOps-mirror deploy variant.", + "properties": { + "mode": { + "type": "string", + "enum": ["dispatch", "gitops"], + "description": "Target mode (default: dispatch via external/notify)." + }, + "repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." }, + "path": { "type": "string", "description": "File to mutate in the target repo." }, + "field": { "type": "string", "description": "Field to bump (for example image.tag)." }, + "value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." } + } + }, + "publishConfig": { + "type": "object", + "additionalProperties": false, + "required": ["workflow"], + "description": "Publish callback invoked after a release is published.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable publish workflow." } + } + }, + "releaseConfig": { + "type": "object", + "additionalProperties": false, + "description": "Release management settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable cascade-managed releases." }, + "tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." } + } + }, + "changelogConfig": { + "type": "object", + "additionalProperties": false, + "description": "Changelog generation settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable changelog generation." }, + "workflow": { "type": "string", "description": "Custom changelog workflow." }, + "contributors": { "type": "boolean", "description": "Include a contributors section." } + } + }, + "notifyConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo"], + "description": "How a satellite repo notifies its primary after dev deploys.", + "properties": { + "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, + "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + } + }, + "externalRepoConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "deploys"], + "description": "An external repository this primary coordinates.", + "properties": { + "repo": { "type": "string", "description": "External repo (for example org/cdk-infra)." }, + "ref": { "type": "string", "description": "Branch or tag reference (default: trunk_branch)." }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/externalDeployConfig" }, + "description": "Deployables from this repo." + } + } + }, + "externalDeployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deployable from an external repository.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Workflow path, local or cross-repo (org/repo/.github/...@ref)." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" } + } + }, + "dispatchInput": { + "type": "object", + "additionalProperties": false, + "description": "A single operator-facing workflow_dispatch input.", + "properties": { + "type": { + "type": "string", + "enum": ["string", "boolean", "choice", "environment", "number"], + "description": "Input type." + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Valid values for a choice input." + }, + "default": { "description": "Default value (any supported type)." }, + "description": { "type": "string", "description": "Operator-facing help text." }, + "required": { "type": "boolean", "description": "Mark the input as required." } + } + }, + "extraTriggers": { + "type": "object", + "additionalProperties": false, + "description": "Config-level non-push trigger types.", + "properties": { + "schedule": { + "type": "array", + "items": { "$ref": "#/definitions/scheduleEntry" }, + "description": "Cron schedule entries." + }, + "repository_dispatch": { + "type": "object", + "additionalProperties": false, + "description": "Enables the repository_dispatch trigger.", + "properties": { + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "workflow_run": { + "type": "object", + "additionalProperties": false, + "description": "Enables the workflow_run trigger.", + "properties": { + "workflows": { "type": "array", "items": { "type": "string" } }, + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "merge_group": { + "type": "object", + "additionalProperties": false, + "description": "When present (even empty), wires the merge-queue trigger." + } + } + }, + "scheduleEntry": { + "type": "object", + "additionalProperties": false, + "required": ["cron"], + "description": "A single cron schedule.", + "properties": { + "cron": { "type": "string", "description": "Cron expression." } + } + }, + "prPreviewConfig": { + "type": "object", + "additionalProperties": false, + "description": "Read-only PR plan-preview lane.", + "properties": { + "enabled": { "type": "boolean" }, + "comment": { "type": "boolean" } + } + }, + "validateCheckConfig": { + "type": "object", + "additionalProperties": false, + "description": "Manifest-validation-as-a-PR-check lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "mergeQueueConfig": { + "type": "object", + "additionalProperties": false, + "description": "Merge-queue validation lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "telemetryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Vendor-neutral metrics seam.", + "properties": { + "enabled": { "type": "boolean" }, + "adapter": { "type": "string", "description": "Metrics adapter (for example none, datadog)." } + } + }, + "environmentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Per-environment settings block.", + "properties": { + "gha_environment": { + "type": "string", + "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)." + } + } + }, + "runPolicy": { + "type": "string", + "enum": ["default", "always", "force"], + "description": "Run policy for the callback." + }, + "onFailure": { + "type": "string", + "enum": ["abort", "continue"], + "description": "Behavior when the callback fails." + } + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 0000000..4611b14 --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,32 @@ +// Package schema embeds the hand-authored JSON Schema for the cascade manifest +// and exposes it to the CLI and to editors. +// +// The canonical, authored schema lives at internal/schema/manifest.schema.json +// and is embedded into the binary. Two on-disk copies are kept byte-identical to +// it for distribution: schema/manifest.schema.json (a stable repo-root path) and +// docs/public/manifest.schema.json (served at the published $id URL). The drift +// test in this package asserts all three copies match, so a maintainer who edits +// one must sync the others. +// +// To regenerate the published copies after editing the canonical schema: +// +// cascade schema --output schema/manifest.schema.json +// cp schema/manifest.schema.json docs/public/manifest.schema.json +package schema + +import _ "embed" + +//go:embed manifest.schema.json +var manifest []byte + +// Bytes returns the embedded manifest JSON Schema as raw bytes. +func Bytes() []byte { + out := make([]byte, len(manifest)) + copy(out, manifest) + return out +} + +// String returns the embedded manifest JSON Schema as a string. +func String() string { + return string(manifest) +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..12551c0 --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,309 @@ +// The tests in this file are the lockstep contract between the hand-authored +// manifest JSON Schema and the Go manifest types. When a manifest field is +// added, changed, or removed in internal/config, this schema must be updated to +// match, and these tests prove it: a corpus of real manifests (cascade's own +// manifest, every e2e scenario, and the README examples) must validate against +// the schema, while a set of known-bad documents must be rejected. A sibling +// test asserts the three on-disk copies of the schema stay byte-identical. +// +// The JSON Schema validator (santhosh-tekuri/jsonschema) is a test-only +// dependency and never enters the cascade binary. +package schema_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/schema" +) + +// repoRoot returns the worktree root, derived from this test file's location +// (internal/schema -> ../..). +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +// compileSchema compiles the embedded schema as draft-07. +func compileSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + doc, err := jsonschema.UnmarshalJSON(strings.NewReader(schema.String())) + if err != nil { + t.Fatalf("unmarshal embedded schema: %v", err) + } + c := jsonschema.NewCompiler() + c.DefaultDraft(jsonschema.Draft7) + const id = "https://stablekernel.github.io/cascade/manifest.schema.json" + if err := c.AddResource(id, doc); err != nil { + t.Fatalf("add schema resource: %v", err) + } + compiled, err := c.Compile(id) + if err != nil { + t.Fatalf("compile schema: %v", err) + } + return compiled +} + +// toJSONValue normalizes a value loaded from YAML into the JSON-like types that +// the validator expects (map[string]any, []any, float64, string, bool, nil) by +// round-tripping through encoding/json. +func toJSONValue(t *testing.T, v any) any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal to json: %v", err) + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal json: %v", err) + } + return out +} + +// loadYAMLDoc decodes a YAML document into a generic map. +func loadYAMLDoc(t *testing.T, data []byte) map[string]any { + t.Helper() + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + t.Fatalf("unmarshal yaml: %v", err) + } + return doc +} + +func TestSchema_ValidatesCascadeOwnManifest(t *testing.T) { + sch := compileSchema(t) + root := repoRoot(t) + + data, err := os.ReadFile(filepath.Join(root, ".github", "manifest.yaml")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + doc := loadYAMLDoc(t, data) + if err := sch.Validate(toJSONValue(t, doc)); err != nil { + t.Fatalf("cascade .github/manifest.yaml must validate: %v", err) + } +} + +func TestSchema_ValidatesE2EScenarioConfigs(t *testing.T) { + sch := compileSchema(t) + root := repoRoot(t) + + matches, err := filepath.Glob(filepath.Join(root, "e2e", "scenarios", "*.yaml")) + if err != nil { + t.Fatalf("glob scenarios: %v", err) + } + if len(matches) == 0 { + t.Fatal("expected at least one e2e scenario manifest") + } + + validated := 0 + for _, path := range matches { + path := path + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read scenario: %v", err) + } + doc := loadYAMLDoc(t, data) + cfg, ok := doc["config"] + if !ok { + t.Skipf("scenario has no config block") + } + // Scenario files are not ci-wrapped; wrap the config block. + wrapped := map[string]any{"ci": map[string]any{"config": cfg}} + if err := sch.Validate(toJSONValue(t, wrapped)); err != nil { + t.Fatalf("scenario config must validate: %v", err) + } + validated++ + }) + } + if validated == 0 { + t.Fatal("no scenario config blocks were validated") + } +} + +func TestSchema_ValidatesREADMEExamples(t *testing.T) { + sch := compileSchema(t) + root := repoRoot(t) + + data, err := os.ReadFile(filepath.Join(root, "README.md")) + if err != nil { + t.Fatalf("read README: %v", err) + } + + blocks := extractYAMLFences(string(data)) + ciBlocks := 0 + for i, block := range blocks { + if !firstMeaningfulLineIsCI(block) { + continue + } + ciBlocks++ + i := i + block := block + t.Run(fmt.Sprintf("readme-ci-block-%d", ciBlocks), func(t *testing.T) { + doc := loadYAMLDoc(t, []byte(block)) + if err := sch.Validate(toJSONValue(t, doc)); err != nil { + t.Fatalf("README ci block #%d must validate: %v", i, err) + } + }) + } + if ciBlocks < 2 { + t.Fatalf("expected at least 2 ci-rooted yaml blocks in README.md, found %d", ciBlocks) + } +} + +// firstMeaningfulLineIsCI reports whether the first non-blank, non-comment line +// of a YAML block is the top-level "ci:" key. +func firstMeaningfulLineIsCI(block string) bool { + for _, line := range strings.Split(block, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + return strings.HasPrefix(trimmed, "ci:") + } + return false +} + +// extractYAMLFences returns the contents of every ```yaml fenced code block. +func extractYAMLFences(md string) []string { + var blocks []string + lines := strings.Split(md, "\n") + inBlock := false + var cur []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !inBlock { + if trimmed == "```yaml" || trimmed == "```yml" { + inBlock = true + cur = nil + } + continue + } + if trimmed == "```" { + inBlock = false + blocks = append(blocks, strings.Join(cur, "\n")) + continue + } + cur = append(cur, line) + } + return blocks +} + +func TestSchema_RejectsKnownBadManifests(t *testing.T) { + sch := compileSchema(t) + + cases := map[string]map[string]any{ + "unknown top-level key": { + "ci": map[string]any{"config": map[string]any{"trunk_branch": "main"}}, + "bogus_top": 1, + }, + "environments as string": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "environments": "dev", + }}, + }, + "secrets as integer": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "builds": []any{map[string]any{ + "name": "app", + "secrets": 7, + }}, + }}, + }, + "build missing name": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "builds": []any{map[string]any{ + "workflow": ".github/workflows/build.yaml", + }}, + }}, + }, + } + + for name, doc := range cases { + doc := doc + t.Run(name, func(t *testing.T) { + if err := sch.Validate(toJSONValue(t, doc)); err == nil { + t.Fatalf("expected validation to fail for %q, but it passed", name) + } + }) + } +} + +func TestSchema_AcceptsSecretsUnionForms(t *testing.T) { + sch := compileSchema(t) + + good := map[string]any{ + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "builds": []any{ + map[string]any{"name": "a", "secrets": "inherit"}, + map[string]any{"name": "b", "secrets": map[string]any{"inherit": true}}, + map[string]any{"name": "c", "secrets": map[string]any{"DB_PASSWORD": "PROD_DB_PASSWORD"}}, + }, + }}, + } + if err := sch.Validate(toJSONValue(t, good)); err != nil { + t.Fatalf("secrets union forms must validate: %v", err) + } +} + +func TestSchema_AcceptsRunsOnUnionForms(t *testing.T) { + sch := compileSchema(t) + + good := map[string]any{ + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "runs_on": "ubuntu-latest", + "builds": []any{ + map[string]any{"name": "a", "runs_on": []any{"self-hosted", "linux"}}, + map[string]any{"name": "b", "runs_on": map[string]any{"group": "gpu", "labels": []any{"a100"}}}, + }, + }}, + } + if err := sch.Validate(toJSONValue(t, good)); err != nil { + t.Fatalf("runs_on union forms must validate: %v", err) + } +} + +func TestSchema_OnDiskCopiesAreByteIdentical(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal", "schema", "manifest.schema.json"), + filepath.Join(root, "schema", "manifest.schema.json"), + filepath.Join(root, "docs", "public", "manifest.schema.json"), + } + + var first []byte + for i, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + if i == 0 { + first = data + // The embedded copy must also match the on-disk canonical file. + if string(schema.Bytes()) != string(data) { + t.Fatalf("embedded schema differs from %s", p) + } + continue + } + if string(data) != string(first) { + t.Fatalf("schema copy %s differs from %s; run: cascade schema --output schema/manifest.schema.json && cp schema/manifest.schema.json docs/public/manifest.schema.json", p, paths[0]) + } + } +} diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json new file mode 100644 index 0000000..2202d6b --- /dev/null +++ b/schema/manifest.schema.json @@ -0,0 +1,622 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://stablekernel.github.io/cascade/manifest.schema.json", + "title": "cascade manifest", + "description": "Structure, types, and enums for a cascade manifest file. The manifest is nested under a top key (default \"ci\", configurable via config.manifest_key) and carries the authoring surface (config), plus cascade-managed promotion state (state, latest_release). This schema powers editor autocomplete and hover docs; cascade parse-config remains the authority for semantic and cross-field rules.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional pointer to this JSON Schema for editor validation." + }, + "ci": { + "$ref": "#/definitions/manifestBody", + "description": "The cascade manifest body. The key name is configurable via config.manifest_key (default \"ci\")." + } + }, + "definitions": { + "manifestBody": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/definitions/trunkConfig", + "description": "The pipeline configuration: the authoring surface for cascade." + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed promotion state keyed by environment name. Authored by cascade, not by hand; modeled permissively so real manifests validate." + }, + "latest_release": { + "type": "object", + "additionalProperties": true, + "description": "cascade-managed record of the most recent published release. Authored by cascade, not by hand." + } + } + }, + "trunkConfig": { + "type": "object", + "additionalProperties": false, + "required": ["trunk_branch"], + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Manifest schema generation. Defaults to the current generation when omitted. A single monotonic integer, not a semver string." + }, + "trunk_branch": { + "type": "string", + "description": "The trunk branch that cascade orchestrates from (for example \"main\")." + }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." + }, + "environments": { + "type": "array", + "items": { "type": "string" }, + "description": "Ordered list of promotion environments. Empty means a no-environment setup (library or CLI projects)." + }, + "cli_version": { + "type": "string", + "description": "cascade CLI version pinned into generated workflows (for example \"v1.0.0\"). Defaults to the pinned release tag when unset or \"latest\"." + }, + "tag_prefix": { + "type": "string", + "description": "Version tag prefix (default: \"v\")." + }, + "release_token": { + "type": "string", + "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "state_token": { + "type": "string", + "description": "GitHub Actions secret expression for writing manifest state back to the trunk branch (default: ${{ secrets.GITHUB_TOKEN }})." + }, + "manifest_file": { + "type": "string", + "description": "Path to the manifest file (default: \".github/manifest.yaml\")." + }, + "manifest_key": { + "type": "string", + "description": "Nested key in the manifest file that holds the body (default: \"ci\")." + }, + "action_folder": { + "type": "string", + "description": "Folder name for the manage-release action (default: \"manage-release\")." + }, + "git": { "$ref": "#/definitions/gitConfig" }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { + "type": "array", + "items": { "$ref": "#/definitions/buildConfig" }, + "description": "Build callbacks. Empty means no orchestrated builds." + }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/deployConfig" }, + "description": "Deploy callbacks. Empty means no deploys (library or CLI projects)." + }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { + "type": "array", + "items": { "$ref": "#/definitions/externalRepoConfig" }, + "description": "External repositories this primary coordinates." + }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { + "$ref": "#/definitions/runsOn", + "description": "Default runner for cascade-owned jobs." + }, + "job_timeout_minutes": { + "type": "integer", + "description": "Default timeout-minutes for cascade-owned jobs." + }, + "dispatch_inputs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, + "description": "Operator-facing manual-run inputs." + }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "merge_queue": { "$ref": "#/definitions/mergeQueueConfig" }, + "pin_mode": { + "type": "string", + "enum": ["tag", "sha"], + "description": "How action references are pinned (default: tag)." + }, + "action_pins": { + "type": "object", + "additionalProperties": { "type": "string" }, + "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." + } + } + }, + "secrets": { + "description": "Secrets passing for a callback. Either the scalar \"inherit\" (pass all caller secrets), the mapping {inherit: true}, or an explicit map of called-workflow secret name to caller secret name (least-privilege). inherit cannot be mixed with explicit keys.", + "oneOf": [ + { "type": "string", "const": "inherit" }, + { + "type": "object", + "additionalProperties": false, + "required": ["inherit"], + "properties": { "inherit": { "type": "boolean" } } + }, + { + "type": "object", + "additionalProperties": { "type": "string" }, + "not": { "required": ["inherit"] } + } + ] + }, + "runsOn": { + "description": "Runner selection. A single label, a list of labels, or a {group, labels} object (at least one of group or labels).", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "group": { "type": "string", "description": "Runner group name." }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Runner labels." + } + }, + "anyOf": [ + { "required": ["group"] }, + { "required": ["labels"] } + ] + } + ] + }, + "concurrencyConfig": { + "type": "object", + "additionalProperties": false, + "description": "Concurrency control for the generated orchestrate workflow.", + "properties": { + "group": { + "type": "string", + "description": "Concurrency group expression (default: orchestrate-${{ github.ref }})." + }, + "cancel_in_progress": { + "type": "boolean", + "description": "Cancel older in-progress runs (default: true). Set false to queue runs instead." + } + } + }, + "permissions": { + "type": "object", + "description": "GITHUB_TOKEN permission scopes mapped to access levels.", + "additionalProperties": { + "type": "string", + "enum": ["read", "write", "none"] + } + }, + "inputs": { + "type": "object", + "additionalProperties": true, + "description": "Freeform inputs passed to the callback workflow." + }, + "envInputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "description": "Per-environment inputs: a map of environment name to a freeform inputs map." + }, + "gitConfig": { + "type": "object", + "additionalProperties": false, + "description": "Git identity and signing configuration.", + "properties": { + "mode": { + "type": "string", + "enum": ["default", "custom", "external"], + "description": "Git identity mode: default (github-actions bot), custom (use user_name/user_email), or external (pre-configured)." + }, + "user_name": { "type": "string", "description": "git user.name (when mode is custom)." }, + "user_email": { "type": "string", "description": "git user.email (when mode is custom)." }, + "gpg_key_id": { "type": "string", "description": "GPG key ID for signing." }, + "gpg_key_secret": { "type": "string", "description": "Secret name containing the GPG key." } + } + }, + "validateConfig": { + "type": "object", + "additionalProperties": false, + "description": "The singleton validation callback.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable validation workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns that should trigger validation." + }, + "supports_dry_run": { "type": "boolean" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" } + } + }, + "buildConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A build callback.", + "properties": { + "name": { "type": "string", "description": "Build identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable build workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/definitions/artifactConfig" } + }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "matrix": { "$ref": "#/definitions/matrixConfig" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "deployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deploy callback.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Path to the reusable deploy workflow." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "depends_on": { "type": "array", "items": { "type": "string" } }, + "state_tags": { "type": "array", "items": { "type": "string" } }, + "supports_dry_run": { "type": "boolean" }, + "run_policy": { "$ref": "#/definitions/runPolicy" }, + "on_failure": { "$ref": "#/definitions/onFailure" }, + "retries": { "type": "integer" }, + "timeout_minutes": { "type": "integer" }, + "inputs": { "$ref": "#/definitions/inputs" }, + "env_inputs": { "$ref": "#/definitions/envInputs" }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" }, + "artifact": { "$ref": "#/definitions/passthroughArtifact" } + } + }, + "artifactConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name", "path"], + "description": "A release artifact produced by a build.", + "properties": { + "name": { "type": "string", "description": "Artifact identifier (for example \"linux-amd64\")." }, + "path": { "type": "string", "description": "Glob pattern for files to include (for example \"dist/*.tar.gz\")." }, + "required": { "type": "boolean", "description": "Fail the release if the artifact is missing (default: true)." } + } + }, + "passthroughArtifact": { + "type": "object", + "additionalProperties": false, + "description": "GitHub Actions artifact passing between jobs within a single orchestrate run.", + "properties": { + "upload": { "type": "string", "description": "Path glob to upload after this job completes." }, + "downloads": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of upstream build jobs whose artifacts to download first." + } + } + }, + "matrixConfig": { + "type": "object", + "additionalProperties": false, + "description": "Build fan-out via a matrix. Builds only.", + "properties": { + "dimensions": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + }, + "description": "Cross-product axes (for example os, arch)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent matrix legs (0 means the platform default)." }, + "fail_fast": { "type": "boolean" } + } + }, + "rolloutConfig": { + "type": "object", + "additionalProperties": false, + "description": "Deploy rollout strategy. Deploys only.", + "properties": { + "type": { + "type": "string", + "enum": ["default", "rolling", "canary", "blue_green"], + "description": "Rollout strategy (default: default)." + }, + "max_parallel": { "type": "integer", "description": "Cap on concurrent rollout waves." }, + "fail_fast": { "type": "boolean" }, + "canary": { "$ref": "#/definitions/canaryConfig" }, + "blue_green": { "$ref": "#/definitions/blueGreenConfig" } + } + }, + "canaryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Canary rollout sub-block, used when rollout type is canary.", + "properties": { + "steps": { + "type": "array", + "items": { "type": "integer" }, + "description": "Percent waves (for example [10, 50, 100])." + }, + "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + } + }, + "blueGreenConfig": { + "type": "object", + "additionalProperties": false, + "description": "Blue/green rollout sub-block, used when rollout type is blue_green.", + "properties": { + "switch": { "type": "string", "description": "Workflow path that performs the blue/green cutover." } + } + }, + "deployTarget": { + "type": "object", + "additionalProperties": false, + "description": "GitOps-mirror deploy variant.", + "properties": { + "mode": { + "type": "string", + "enum": ["dispatch", "gitops"], + "description": "Target mode (default: dispatch via external/notify)." + }, + "repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." }, + "path": { "type": "string", "description": "File to mutate in the target repo." }, + "field": { "type": "string", "description": "Field to bump (for example image.tag)." }, + "value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." } + } + }, + "publishConfig": { + "type": "object", + "additionalProperties": false, + "required": ["workflow"], + "description": "Publish callback invoked after a release is published.", + "properties": { + "workflow": { "type": "string", "description": "Path to the reusable publish workflow." } + } + }, + "releaseConfig": { + "type": "object", + "additionalProperties": false, + "description": "Release management settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable cascade-managed releases." }, + "tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." } + } + }, + "changelogConfig": { + "type": "object", + "additionalProperties": false, + "description": "Changelog generation settings.", + "properties": { + "disabled": { "type": "boolean", "description": "Disable changelog generation." }, + "workflow": { "type": "string", "description": "Custom changelog workflow." }, + "contributors": { "type": "boolean", "description": "Include a contributors section." } + } + }, + "notifyConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo"], + "description": "How a satellite repo notifies its primary after dev deploys.", + "properties": { + "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, + "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + } + }, + "externalRepoConfig": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "deploys"], + "description": "An external repository this primary coordinates.", + "properties": { + "repo": { "type": "string", "description": "External repo (for example org/cdk-infra)." }, + "ref": { "type": "string", "description": "Branch or tag reference (default: trunk_branch)." }, + "deploys": { + "type": "array", + "items": { "$ref": "#/definitions/externalDeployConfig" }, + "description": "Deployables from this repo." + } + } + }, + "externalDeployConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "A deployable from an external repository.", + "properties": { + "name": { "type": "string", "description": "Deploy identifier." }, + "workflow": { "type": "string", "description": "Workflow path, local or cross-repo (org/repo/.github/...@ref)." }, + "triggers": { + "type": "array", + "items": { "type": "string" }, + "description": "Path patterns for change detection." + }, + "secrets": { "$ref": "#/definitions/secrets" }, + "permissions": { "$ref": "#/definitions/permissions" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "rollout": { "$ref": "#/definitions/rolloutConfig" }, + "deploy_target": { "$ref": "#/definitions/deployTarget" }, + "optional_depends_on": { "type": "array", "items": { "type": "string" } }, + "auto_commits": { "type": "boolean" } + } + }, + "dispatchInput": { + "type": "object", + "additionalProperties": false, + "description": "A single operator-facing workflow_dispatch input.", + "properties": { + "type": { + "type": "string", + "enum": ["string", "boolean", "choice", "environment", "number"], + "description": "Input type." + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Valid values for a choice input." + }, + "default": { "description": "Default value (any supported type)." }, + "description": { "type": "string", "description": "Operator-facing help text." }, + "required": { "type": "boolean", "description": "Mark the input as required." } + } + }, + "extraTriggers": { + "type": "object", + "additionalProperties": false, + "description": "Config-level non-push trigger types.", + "properties": { + "schedule": { + "type": "array", + "items": { "$ref": "#/definitions/scheduleEntry" }, + "description": "Cron schedule entries." + }, + "repository_dispatch": { + "type": "object", + "additionalProperties": false, + "description": "Enables the repository_dispatch trigger.", + "properties": { + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "workflow_run": { + "type": "object", + "additionalProperties": false, + "description": "Enables the workflow_run trigger.", + "properties": { + "workflows": { "type": "array", "items": { "type": "string" } }, + "types": { "type": "array", "items": { "type": "string" } } + } + }, + "merge_group": { + "type": "object", + "additionalProperties": false, + "description": "When present (even empty), wires the merge-queue trigger." + } + } + }, + "scheduleEntry": { + "type": "object", + "additionalProperties": false, + "required": ["cron"], + "description": "A single cron schedule.", + "properties": { + "cron": { "type": "string", "description": "Cron expression." } + } + }, + "prPreviewConfig": { + "type": "object", + "additionalProperties": false, + "description": "Read-only PR plan-preview lane.", + "properties": { + "enabled": { "type": "boolean" }, + "comment": { "type": "boolean" } + } + }, + "validateCheckConfig": { + "type": "object", + "additionalProperties": false, + "description": "Manifest-validation-as-a-PR-check lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "mergeQueueConfig": { + "type": "object", + "additionalProperties": false, + "description": "Merge-queue validation lane.", + "properties": { + "enabled": { "type": "boolean" } + } + }, + "telemetryConfig": { + "type": "object", + "additionalProperties": false, + "description": "Vendor-neutral metrics seam.", + "properties": { + "enabled": { "type": "boolean" }, + "adapter": { "type": "string", "description": "Metrics adapter (for example none, datadog)." } + } + }, + "environmentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Per-environment settings block.", + "properties": { + "gha_environment": { + "type": "string", + "description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)." + } + } + }, + "runPolicy": { + "type": "string", + "enum": ["default", "always", "force"], + "description": "Run policy for the callback." + }, + "onFailure": { + "type": "string", + "enum": ["abort", "continue"], + "description": "Behavior when the callback fails." + } + } +}