Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.
A minimal {{ path.to.value }} syntax in both pipeline configs (pipeline.json) and plugin specs (plugin-spec.yaml).
{{ ... }} tokens behaves exactly as it did before.pipeline.metadata.*.requiredMetadata is checked at pipeline create and again at synth, so a missing key fails early rather than mid-build.This reference documents the grammar, the scopes, filters, the plugin contract, the CLI and editor tooling, and the error catalog. It is for plugin and pipeline authors who want a single spec to serve many environments.
Two uses drive the design: one plugin serving many environments, and one pipeline template serving many deployments via self-references.
{{ ... }} tokens in a pipeline config or plugin spec.requiredMetadata is checked when the pipeline is created and again at synth.requiredMetadata plus metadataTypes. See Plugin contract.?resolve=true when you want the API to hand back the resolved form; see API.Template := (Literal | Expr)*
Expr := "{{" ws Path (ws Filter)? ws "}}"
Path := Identifier ("." Identifier)*
Identifier := [a-zA-Z_][a-zA-Z0-9_]{0,63}
Filter := "|" ws "default" ws ":" ws Quoted
Quoted := "'...'" | "\"...\""
{{ as {{{{ (doubled). The }} that closes it is then literal
too, so a Go / Helm / GitHub-style template passes through untouched:
docker inspect -f '{{{{.State.Status}}' runs as docker inspect -f '{{.State.Status}}'.
An unmatched }} is still rejected.| default: '...', | number, | bool, | json.default may appear once; at most one coercion filter per expression.Different docs see different scopes.
pipeline.json)Pipeline templates can only self-reference — one metadata key can interpolate another, or reference a vars key.
| Scope root | Available inside |
|---|---|
metadata.* |
Any other metadata key in the same pipeline |
vars.* |
Any vars key in the same pipeline |
Templatable fields in a pipeline config: project, metadata.* string values, vars.* string values. Identity fields (id, orgId, stages, plugins[]) are not templatable.
Exception — the GitHub source token. synth.source.options.token is templatable against the pipeline scope (pipeline.*, the same scope plugin specs see), so a secret reference can be parameterized per org:
"synth": {
"source": {
"type": "github",
"options": {
"repo": "owner/repo",
"branch": "main",
"token": "secretsmanager:pipeline-builder/{{ pipeline.vars.orgId }}/github-token"
}
}
},
"vars": { "orgId": "<your-org-id>" }
It resolves at synth time to secretsmanager:pipeline-builder/<your-org-id>/github-token, which CodePipeline reads as the OAuth secret reference (only the reference — never the token value — lands in the template). Other source fields (repo, branch, etc.) stay literal.
plugin-spec.yaml)Plugin templates see a richer scope assembled per-synth from the pipeline invoking the plugin.
| Scope root | Available inside |
|---|---|
pipeline.projectName |
String — the pipeline’s project name |
pipeline.orgId |
String — org UUID |
pipeline.metadata.* |
Any key set on the pipeline’s metadata object |
pipeline.vars.* |
Any key set on the pipeline’s vars object |
plugin.name / plugin.version |
Plugin record fields |
env.FOO |
Any key declared in the same plugin’s env: map. env values may reference each other (B: '{{ env.A }}-svc'); they resolve in dependency order, and a cycle is an error. |
Templatable fields in a plugin spec: description, commands[], installCommands[], env.* values, buildArgs.* values. Identity/security fields (name, version, pluginType, computeType, timeout, secrets, failureBehavior) are not templatable.
secrets.* is reserved — use the plugin’s secrets: yaml field instead of templating.process.env) are not in scope — they will never leak into a template.When a plugin spec references pipeline.metadata.X or pipeline.vars.Y, it must declare that dependency so pipelines using the plugin are rejected if they don’t supply the key.
name: kubectl-deploy
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL
# Contract — pipelines using this plugin must set these metadata keys
requiredMetadata: [env, namespace, clusterName, region]
requiredVars: []
env:
KUBECONFIG: /tmp/{{ pipeline.metadata.env }}-kubeconfig
installCommands:
- "aws eks update-kubeconfig --name {{ pipeline.metadata.clusterName }} --region {{ pipeline.metadata.region }}"
commands:
- "kubectl apply -f k8s/{{ pipeline.metadata.env }}/ -n {{ pipeline.metadata.namespace }}"
If a template uses | default: '...', the key is treated as optional and can be omitted from requiredMetadata / requiredVars.
The contract is stored with each plugin version, and every pipeline create and update (single and bulk) is checked against it:
synth.plugin and every stages[].steps[].plugin) is resolved the same way synth resolves it (see Plugin references below). Listed versions from the ecosystem are checked too.pipeline.metadata is global, then defaults.metadata, then synth.metadata (the last one wins). Every key in requiredMetadata must be present there, and every key in requiredVars must be present in vars. Absent, null and "" all count as missing.metadataTypes / varsTypes must coerce the way its filter would: number is numeric, bool is one of true/false/1/0/yes/no, and json is a JSON string. Objects and arrays are never valid, because they can’t be interpolated. A value that is still a {{ … }} template isn’t type-checked, since it’s resolved later.400 TEMPLATE_CONTRACT_VIOLATION. steps[] lists every failing step (path, step, plugin, version, missing[], invalid[]).publisher) that isn’t installed, is blocked by the org’s consumption policy or can’t resolve is refused at create and update with 400 and the per-step reasons.publisher and version rangesA step’s plugin names the plugin, and optionally its publisher and a version range:
plugin: { name: trivy } # unqualified
plugin: { name: trivy, filter: { version: '^1' } } # unqualified, 1.x
plugin: { publisher: acme, name: terraform-plan, filter: { version: '^1' } } # qualified: acme's listing
plugin: { publisher: pipeline-builder, name: trivy } # the Official listing, by name
PLUGIN_SHADOWS_LISTING.filter.version: an exact version, ^1, ~1.4, 1.x, 1.2 or latest. A version key beside name is refused. Without one, an own-org plugin resolves its default version.filter.version. A range outside the install fails with PLUGIN_NOT_INSTALLED (reason version_outside_install). For an implicit Official install, filter.version replaces the implicit <major>.x range.publisher, name and filter are identity fields and aren’t templatable. See Plugin Installing.
{
"id": "bb234ff6-8b2e-41e3-9758-fb23b63916cd",
"project": "{{ vars.service }}-{{ metadata.env }}",
"orgId": "acmecorp",
"metadata": {
"env": "prod",
"region": "us-east-1",
"clusterName": "acme-eks-{{ metadata.env }}",
"namespace": "{{ vars.service }}-{{ metadata.env }}"
},
"vars": {
"service": "checkout",
"branch": "main",
"slackChannel": "#deploys-{{ metadata.env }}"
},
"stages": [
{ "name": "deploy", "plugins": ["kubectl-deploy", "slack-notify"] }
]
}
After pass-1 resolution, the pipeline looks like:
{
"project": "checkout-prod",
"metadata": {
"env": "prod",
"region": "us-east-1",
"clusterName": "acme-eks-prod",
"namespace": "checkout-prod"
},
"vars": {
"service": "checkout",
"branch": "main",
"slackChannel": "#deploys-prod"
}
}
Cycles are detected and rejected at upload time:
POST /api/pipelines → 400 TEMPLATE_VALIDATION_FAILED
Pipeline has circular template references:
• Template cycle detected: metadata.a -> metadata.b -> metadata.a
pipeline.* interpolationBefore templates — hardcoded per environment:
# plugins/deploy/kubectl-deploy-prod/plugin-spec.yaml
name: kubectl-deploy-prod
commands:
- "kubectl apply -f k8s/prod/ -n checkout-prod"
- "kubectl scale deployment checkout --replicas=3 -n checkout-prod"
After templates — one plugin serves N environments:
# plugins/deploy/kubectl-deploy/plugin-spec.yaml
name: kubectl-deploy
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL
requiredMetadata: [env, namespace, replicas]
requiredVars: []
env:
NAMESPACE: "{{ pipeline.metadata.namespace }}"
installCommands:
- "kubectl config use-context {{ pipeline.metadata.env }}"
commands:
- "kubectl apply -f k8s/{{ pipeline.metadata.env }}/ -n {{ env.NAMESPACE }}"
- "kubectl scale deployment {{ pipeline.projectName }} --replicas={{ pipeline.metadata.replicas | default: '1' }} -n {{ env.NAMESPACE }}"
# plugins/notification/slack-notify/plugin-spec.yaml
name: slack-notify
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL
requiredMetadata: [env]
requiredVars: [slackChannel]
secrets:
- name: SLACK_WEBHOOK_URL
required: true
commands:
- |
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"channel": "{{ pipeline.vars.slackChannel }}",
"text": "✅ {{ pipeline.projectName }} deployed to {{ pipeline.metadata.env }} from {{ pipeline.vars.branch | default: 'unknown' }}"
}'
# plugins/artifact/docker-build-push/plugin-spec.yaml
name: docker-build-push
version: 2.0.0
pluginType: CodeBuildStep
computeType: MEDIUM
requiredMetadata: [region, ecrRepoName]
requiredVars: []
buildArgs:
BUILD_ENV: "{{ pipeline.metadata.env | default: 'staging' }}"
COMMIT_SHA: "$CODEBUILD_RESOLVED_SOURCE_VERSION" # literal — runtime var
commands:
- "aws ecr get-login-password --region {{ pipeline.metadata.region }} | docker login --password-stdin {{ pipeline.orgId }}.dkr.ecr.{{ pipeline.metadata.region }}.amazonaws.com"
- "docker build --build-arg BUILD_ENV=$BUILD_ENV -t {{ pipeline.metadata.ecrRepoName }}:$COMMIT_SHA ."
- "docker push {{ pipeline.metadata.ecrRepoName }}:$COMMIT_SHA"
Note the mix: {{ ... }} is resolved at synth time, while $CODEBUILD_* variables stay literal and are evaluated at runtime by the shell.
| default: '...' — fallback valueUse | default: '...' to supply a fallback when a scope path is undefined or empty:
commands:
- "kubectl scale deployment {{ pipeline.projectName }} --replicas={{ pipeline.metadata.replicas | default: '1' }}"
- "curl -s https://api.example.com/{{ pipeline.metadata.endpoint | default: 'v1/health' }}"
\\, \', \" are supported inside the quoted default.| default:, the referenced key does not need to appear in requiredMetadata / requiredVars.| number, | bool, | json — type coercionCoercion filters turn the resolved text into a native value, but only when the template is the entire field (no surrounding literal text):
# Whole-field template — produces native types
metadata:
replicas: "{{ vars.count | number }}" # → 3 (number)
isProd: "{{ vars.env | bool }}" # → true
features: "{{ vars.featureJson | json }}" # → parsed JSON
# Mixed with literal text — stays a string (the filter is ignored)
env:
MSG: "count={{ vars.count | number }}" # → "count=3" (string)
Coercion rules:
| Filter | Accepts | Produces |
|---|---|---|
\| number |
Any string Number() can parse | number |
\| bool |
true / false / 1 / 0 / yes / no / "" (case-insensitive) |
boolean |
\| json |
Any valid JSON string | string \| number \| bool \| null \| object \| array |
Coercion filters can chain with default:
replicas: "{{ vars.replicas | default: '1' | number }}" # → 1 (number) if vars.replicas missing
Unparseable values (e.g. "abc" | number) throw TEMPLATE_TYPE_MISMATCH at synth time.
# Print what the pipeline will look like after resolution (no CDK deploy)
pipeline-manager pipeline deploy --id <uuid> --show-resolved
pipeline-manager pipeline synth --id <uuid> --show-resolved
# Validate a local plugin-spec.yaml or pipeline.json before upload
pipeline-manager template validate --file ./plugin-spec.yaml
# Validate a pipeline by ID against the platform
pipeline-manager template validate --pipeline <uuid>
# Validate a published plugin
pipeline-manager template validate --plugin kubectl-deploy:2.0.0
The dashboard editor understands {{ ... }} tokens:
useTemplateValidation(source, scope?) hook powers this feedback. It returns { valid, tokens, hasTemplates, error, errorPos, resolved, resolveError }, so any custom editor can parse for diagnostics and (when a scope is supplied) preview the resolved value live.No rich editor, no auto-escape — what you type is saved verbatim. Template resolution is a server-side concern; the client only parses for diagnostics + preview.
?resolve=truePipeline read endpoints return the source by default (with {{ ... }} intact) so editors can round-trip. Pass ?resolve=true to get the resolved form.
GET /api/pipelines/{id} # source form (for editing)
GET /api/pipelines/{id}?resolve=true # resolved form (for preview/inspection)
All template errors map to HTTP 400 with one of these codes:
| Code | Meaning |
|---|---|
TEMPLATE_PARSE_ERROR |
Malformed {{ ... }} — bad syntax, missing }}, unknown filter |
TEMPLATE_UNKNOWN_PATH |
Path references an unknown scope root (e.g. {{ foo.bar }}) |
TEMPLATE_CYCLE |
Self-referencing pipeline has a cycle across metadata/vars fields |
TEMPLATE_TYPE_MISMATCH |
Path resolved to an object where a scalar was expected |
TEMPLATE_SECRETS_RESERVED |
Reserved secrets.* path — use the plugin’s secrets: yaml field instead |
TEMPLATE_CONTRACT_VIOLATION |
Pipeline create/update: a plugin step’s required metadata/vars key is missing, or a supplied value doesn’t match metadataTypes/varsTypes. steps[] lists each failing step’s missing[] and invalid[] |
TEMPLATE_SIZE_EXCEEDED |
Field exceeded 4 KiB or path depth exceeded 5 |
TEMPLATE_VALIDATION_FAILED |
Batched umbrella — one or more of the above present in a single doc |
Every error includes field, line, col (when applicable), and the exact path or cycle that triggered it.
if-else — prefer separate plugins or a thin shell wrappercommands:{{ ... }} is resolved once at synth time, never againbuildArgs:secrets:, name:, version:, pluginType: — identity/security-sensitive fields are literal-only{{ ... }} tokens (prevents template-injection through user-supplied metadata)Adopting templates on an existing plugin changes nothing for current callers when you use | default::
# plugin-spec.yaml
requiredMetadata: [] # pipeline.metadata keys you require (empty if all optional)
requiredVars: []
metadataTypes: # type hints enable coercion safety
replicas: number
isProd: bool
varsTypes:
branch: string
Before:
env:
KUBE_NAMESPACE: default
ROLLOUT_TIMEOUT: "300s"
After:
env:
KUBE_NAMESPACE: "{{ pipeline.metadata.namespace | default: 'default' }}"
ROLLOUT_TIMEOUT: "{{ pipeline.metadata.rolloutTimeoutSeconds | default: '300' }}s"
Minor bump (e.g. 1.0.0 → 1.1.0) so pipelines can pin the pre-template version if needed.
Five production plugins now show the pattern:
| Plugin | Metadata keys used |
|---|---|
| notification/slack-notify | env, vars.branch, vars.slackChannel, projectName |
| notification/teams-notify | env, vars.branch, projectName |
| deploy/kubectl-deploy | context, namespace, manifestPath, rolloutTimeoutSeconds |
| deploy/helm-deploy | namespace, helmRelease, helmChart, helmTimeoutSeconds |
| deploy/ecs-deploy | ecsCluster, ecsService, imageUri, ecsTaskFamily |
All pipelines continue to work unchanged; when they start supplying metadata keys, the plugin auto-populates the env vars.
“Template references unknown scope root ‘foo’“
→ Only pipeline, plugin, env (for plugins) and metadata, vars (for pipelines) are accepted scope roots.
“‘secrets’ is a reserved scope”
→ Move secret references into the plugin’s top-level secrets: yaml field. Secrets Manager handles the injection as env vars.
“Plugin spec uses template paths not declared in contract”
→ Add the missing key to your plugin’s requiredMetadata: or requiredVars: list, or use | default: '...' to make it optional.
“Template cycle detected”
→ One of your metadata.* or vars.* fields references another that references back to the first. The error message includes the full cycle chain.
{{ ... }} still visible in my CodeBuild logs
→ The plugin was loaded without pipelineScope — this happens only in legacy direct-invocation paths. The platform-managed synth flow always passes scope.
A golden pipeline template is a reusable, governed pipeline config — a BuilderProps with {{ vars.* }} placeholders — plus a set of declared inputs. Teams instantiate it (filling the inputs) to spin up a real pipeline in a few fields instead of hand-building one. It’s the same {{ … }} engine documented above: each declared input becomes a vars.<name> value, baked in at instantiate time.
Create one from an existing pipeline — Save as template (a pipeline’s detail page) or Templates → New template — or Import a template JSON on the Templates page. Declare inputs (e.g. repoUrl, branch) and parameterize the repository so one template targets any repo: the authoring form swaps the source pipeline’s concrete repo URL for {{ vars.repoUrl }} (the “Parameterize repository” button auto-detects it).
project / organization come from the Use-template form; repoUrl / branch are declared inputs:
{
"name": "node-service",
"category": "backend",
"description": "Golden path: Node build -> tests -> CDK deploy, pointed at any repo.",
"inputs": [
{ "name": "repoUrl", "label": "Repository URL", "type": "string", "required": true },
{ "name": "branch", "label": "Branch", "type": "string", "default": "main" }
],
"props": {
"synth": {
"plugin": { "name": "cdk-synth" },
"source": { "repositoryUrl": "{{ vars.repoUrl }}", "branch": "{{ vars.branch }}" }
},
"stages": [
{ "stageName": "build", "steps": [{ "plugin": { "name": "nodejs" } }] },
{ "stageName": "test", "steps": [{ "plugin": { "name": "jest" } }] },
{ "stageName": "deploy", "steps": [{ "plugin": { "name": "cdk-deploy" } }] }
]
}
}
Use it — Templates → Use template → enter Project, Target repository (Git URL) and Branch → Create. The instantiate call renders the vars into the props, then creates the pipeline (compliance + quota still apply):
POST /api/pipeline-templates/<id>/instantiate
{
"project": "checkout",
"organization": "acme",
"inputs": { "repoUrl": "https://github.com/acme/checkout", "branch": "main" }
}
// -> props.synth.source.repositoryUrl == "https://github.com/acme/checkout"
// a new pipeline builds github.com/acme/checkout
Visibility — templates use the same three-rung ladder every catalog entity uses (Permissions → the visibility ladder); templates differ only in that they default to private:
| Rung | Who can see it | Who can edit it |
|---|---|---|
private (default) |
Only you — a personal draft | Only you |
org |
Everyone in your organization | Anyone with templates:write |
public |
Your org and its teams; from the system org, every org | templates:publish |
New templates start private, so you can iterate before sharing. Moving one to public needs templates:publish — a caller without it is clamped to org rather than silently dropped back to a draft. The shared system catalog across all orgs is a superadmin action from the system org.
| Related docs: Metadata Keys | CDK Usage | Plugin Catalog | API Reference |