Hangar

Docs · Glidepath

cicd.yaml reference

cicd.yaml at an app repo’s root is the only file a developer edits to control their pipelines. There is no Tekton YAML to write, read, or understand - the platform owns everything under .tekton/ (pure boilerplate, generated at onboarding, see install-guide.md) and the shared catalog (catalog/).

This doc covers every field. If you just want a working file fast, see quickstart.md or copy one of examples/ instead: for a cloud app, 11-aws-ecs.yaml or 12-aws-lambda.yaml is a complete, commented file to start from. A repository that builds nothing (configuration only) leaves build out: see Checks-only repositories.

cicd.yaml fields mapped to what actually runs: build/deploy/release blocks each drive one stage, an undeclared stage never runs

Everything, annotated

Every field this schema accepts, in one place, with an inline comment explaining what it does, its default, and whether it’s actually load-bearing yet. Nothing here is required except what’s marked REQUIRED - most apps use a small fraction of this file.

# --- Identity - both required, both fixed values today ---
apiVersion: platform/v1        # REQUIRED. Only one valid value.
kind: PipelineConfig           # REQUIRED. Only one valid value.

# --- build: REQUIRED for an app. Only `agent` inside it is required. Leave the whole block
# out for a checks-only repository (see "Checks-only repositories" below). ---
build:
  agent: nodejs-20              # REQUIRED. One of: nodejs-18, nodejs-20, nodejs-22,
                                 # openjdk-17, openjdk-21, python-3.11, go-1.22.
                                 # A named platform-catalog image, not a raw image
                                 # reference - keeps the step portable and centrally
                                 # patchable. Used for unit tests regardless of which
                                 # build strategy (below) you pick.
  script: ./build.sh            # Optional. Omit entirely to build your whole app
                                 # inside a multi-stage Containerfile instead (BuildKit
                                 # builds `containerfile` directly, build-source is
                                 # skipped). Presence/absence is the switch - there's
                                 # no separate boolean, so a typo can't silently
                                 # disable it. Must be bash.
  containerfile: ./Containerfile # Optional, default "./Containerfile" ("Containerfile"
                                 # is this platform's vendor-neutral term for what
                                 # Docker calls a Dockerfile - same file, same format).
                                 # Packaging step if `script` is set; the whole build
                                 # if it's not.
  unitTest:
    enabled: true                # Optional, default true. false skips the unit-test
                                  # command but the stage still runs (span, notify).
    command: ./test.sh           # Optional, default "./test.sh".
  sonar: false                   # Optional, default false. Reserved field - see
                                  # features.md for current status.
  cache:
    enabled: false                # Optional, default false. Only applies to the
                                   # `script` build path - the Containerfile-only path
                                   # gets BuildKit's registry layer cache instead, free.
    size: small                   # Optional, default "small". One of small (1Gi),
                                   # medium (2.5Gi), large (5Gi), xlarge (8Gi). Not
                                   # resizable after onboarding without losing the
                                   # cached content. No `type` field - derived from
                                   # `agent`'s prefix (nodejs-* -> npm cache,
                                   # openjdk-* -> Maven local repo). A no-op for
                                   # python-*/go-1.22 agents (not supported yet).
  sourceVolume:
    size: small                   # Optional, default "small". One of small (2Gi),
                                   # medium (5Gi), large (10Gi), xlarge (20Gi). Sizes
                                   # the ephemeral `source` workspace every stage's
                                   # PipelineRun gets (checked-out repo + build output +
                                   # the image build context) - bump this if a build fails
                                   # with "no space left on device", NOT build.cache
                                   # (that's a separate, persistent dependency cache).
                                   # One value applies to every stage's source workspace
                                   # for this app, not build-only.

# --- test: optional block. See "The test: block" below for the full story. ---
test:
  enabled: true                 # Optional, default true. false skips the actual
                                 # test command but the stage still runs.
  name: integration              # The app-wide default TestWorkflow name every `test`
                                 # step inherits unless it sets its own `testName`. One
                                 # of this or a step's own `testName` MUST resolve to a
                                 # value - checked by preflight.

# --- api: optional block. See "The api: block" below. ---
api:
  spec: openapi.yaml             # Optional, default openapi.yaml at the repo root. The
                                 # build signs this file onto the image; no file = no API.
  type: openapi                  # Optional, default openapi. openapi | asyncapi.

# --- deploy: optional block. See "The deploy: block" below. ---
# Deploying to AWS ECS or Lambda instead of Kubernetes? Start from examples/11-aws-ecs.yaml
# or examples/12-aws-lambda.yaml: complete files with every cloud field explained.
deploy:
  target: k8s-rollout            # Optional, default k8s-rollout: an Argo Rollout on this
                                 # platform's clusters. Or aws-ecs, aws-lambda,
                                 # azure-container-apps: the deploy stage updates that cloud
                                 # resource instead. Per app, not per environment.
  environments:                  # Optional, default [{name: dev, tier: ground}]. Every
                                 # environment, once, in promotion order. A deploy step's
                                 # `env` (below, under pipelines:) MUST be listed here, a
                                 # release step's must be tier: flight, or preflight rejects
                                 # the flow before anything runs.
    - name: dev                  # REQUIRED. Lowercase letters, digits, -; at most 31 chars.
      tier: ground               # REQUIRED. ground: deployed on every push by a deploy step.
                                 # flight: deployed only through an approved release (a
                                 # gitops release PR; for a cloud target, a release pin PR
                                 # on this repo, ADR-0020).
    - name: prod
      tier: flight
      cluster: prod         # Flight only: the cluster this env runs on, when it isn't
                                 # the app's own (Kubernetes targets). See multi-cluster.md.
      production: true           # Optional. The env serving real users; must be flight.
      # chart: {...}             # Optional per-env override of deploy.chart (below).
      # ecs / lambda / azureContainerApps: this env's cloud resource, for a cloud target.
      #   Fields set here override the app-level block below, field by field:
      # ecs: { cluster: my-app, service: my-app-prod, containerName: my-app, region: us-east-1 }
  # --- Cloud targets: the app-level resource (each env may override it, above). Copy the
  # values from the Airframe target's status.cicd; Glidepath never creates the resource.
  # ecs:                         # target: aws-ecs. An AwsEcsTarget creates all of it.
  #   cluster: my-app            #   the ECS cluster
  #   service: my-app-dev        #   the service; its ECR repo and deploy role are named after it
  #   containerName: my-app      #   the container whose image each deploy replaces
  #   region: us-east-1          #   default us-east-1
  #   taskDefinitionFamily: ""   #   default <app>-<env>
  # lambda:                      # target: aws-lambda. An AwsLambdaTarget creates it.
  #   functionName: my-fn        #   its deploy role is glidepath-deploy-<functionName>
  #   region: us-east-1
  # azureContainerApps:          # target: azure-container-apps.
  #   resourceGroup: my-rg
  #   appName: my-app
  # No AWS secrets: cloud deploys assume the target's deploy role with a short-lived token
  # (docs/admin/cloud-deploy-identity.md). Azure still uses app secrets (known gap 43).
  strategy: rollout              # Optional, default "rollout" (Argo Rollouts
                                 # canary/blue-green) - the only strategy actually
                                 # implemented. "deployment" is still schema-valid
                                 # but has NO EFFECT - nothing renders a plain
                                 # Deployment anymore.
  # releaseFile: "glidepath/envs/{env}.release.yaml"  # Optional (Kubernetes): where the
                                 # deploy/release stages write the machine-owned release
                                 # record. Exactly one {env} placeholder.
  # chart: { repoURL: ..., path: ..., targetRevision: ... }  # Optional (Kubernetes): the
                                 # Helm chart environments render; unset = the cluster's
                                 # default (Airframe's airframe-application). See below.

# --- ephemeralEnvironments: optional block. See features.md for the full writeup. ---
ephemeralEnvironments:
  branch:
    enabled: false                # Optional, default false. Spin up a real,
                                   # temporary deploy per matching branch.
    patterns: ["preview/*"]       # Optional, default ["preview/*"]. Glob(s) a
                                   # branch name must match to get its own env.
  pullRequest:
    enabled: false                # Optional, default false. Same idea, gated on a
                                   # PR label instead of a branch name.
    labels: ["preview"]           # Optional, default ["preview"]. PR must carry one
                                   # of these labels to get an ephemeral env.
  ttl: 5d                        # Optional, default "5d". Pattern: digits + h or d.
                                 # How long an ephemeral env survives after its last
                                 # successful deploy before automatic teardown.

# --- governance: optional block. Real gates, not stubs - see features.md. ---
governance:
  sast: false                    # Optional, default false. Real Semgrep scan.
  imageScan: false               # Optional, default false. Real Trivy scan.
  policyCheck: false             # Optional, default false. Only gates a build-time
                                 # shift-left copy of this check, if one exists - the
                                 # release-gate gitsign commit-signature check itself
                                 # always runs regardless of this flag (release gates
                                 # are enforced, not app-configurable).
  sbom: false                    # Optional, default false. Real cosign SBOM
                                 # attestation.
  githubMerge:                   # Optional. TIGHTEN-ONLY knobs for the provenance
                                 # gate's GitHub-merge path. Whether a GitHub PR merge
                                 # counts for this app, who may merge, and who may sign
                                 # are the admin-owned release-signing policy, not this
                                 # file (docs/admin/commit-signing.md).
    mode: ""                     # Optional, default "". "strict" also requires every
                                 # commit in the merged PR to be gitsign-signed by an
                                 # allowed signer; "" or "merger" = the admin's mode.
    requiredApprovals: 0         # Optional, default 0, max 10. Approvals of the PR's
                                 # final head by allowed mergers other than the merger;
                                 # the higher of this and the admin's number applies.
  allowedCommitSigners: []       # DEPRECATED, IGNORED since 2026-10-20. Signers are the
                                 # admin-owned release-signing policy now (a team could
                                 # add itself here to the list that gated it). Still
                                 # accepted so existing files validate; remove it.

# --- notifications: optional block. ---
notifications:
  slack:
    enabled: false                # Optional, default false. Per-stage pass/fail
                                   # notifications to `channel`.
    channel: "#team-deploys"      # Required if slack.enabled is true. No default.
    scanResults: true             # Optional, default true. Separate, shift-left
                                   # notifications the moment a real governance scan
                                   # (sast/imageScan) produces a result, independent
                                   # of the general per-stage notification above. Only
                                   # takes effect when slack.enabled is also true.
  backstage:
    enabled: false                # Optional, default false. Same general per-stage
                                   # pass/fail notification as slack.enabled above, sent
                                   # instead (or as well - independent toggles) into
                                   # Backstage's own Notifications plugin, broadcast to
                                   # every Backstage user and visible in Tower's
                                   # Notifications tab. No channel/scanResults fields -
                                   # broadcast-only, general per-stage notification only.
                                   # Requires the cluster's glidepath-catalog chart to be
                                   # configured with backstageBaseUrl and a
                                   # glidepath-backstage-notify token Secret - see
                                   # ../admin/notifications.md. Silently no-ops on a
                                   # cluster where that isn't set up.

# --- secrets: optional. Pulls keys from this app's own backend secret store. ---
# See ../admin/app-secrets.md - open-ended by design, not just for Slack.
secrets:
  - name: slack-webhook-url     # Becomes a key in this app's app-secrets Kubernetes
                                 # Secret. A consuming Task (notify-slack.yaml today)
                                 # mounts app-secrets and reads this exact filename.
  - name: sast-scan-token       # Any future purpose works the same way - no schema or
    key: sast-creds-token       # template change needed, just a new entry here. `key`
                                 # (optional) is the name as it exists in the backend
                                 # store, when different from the name you want here -
                                 # defaults to `name` when omitted, as in the entry above.

# --- pipelines: optional, but the whole point of this file for most apps. ---
# A map of named flows. Each flow has a `trigger` (fires the FIRST step only) and an
# ordered `steps` list (chaining is purely positional - see "How flows work" below).
pipelines:
  <flow-name>:                  # Any name you want - becomes part of generated
                                 # Trigger/PipelineRun object names, so keep it short
                                 # and identifier-safe.
    trigger:
      source: git                # "git" (PaC/webhook-triggered - only valid for a
                                  # flow's FIRST step) or "event" (CDEvents broker -
                                  # not yet supported for a flow's first step, only
                                  # meaningful internally).
      event: push                # One of: push, pull_request, tag, branch.created
                                  # (git sources); deploy/artifact.published/
                                  # testcaserun.finished/service.deployed/
                                  # change.created (internal CDEvents, not for you to
                                  # set directly). No release.created - GitHub sends
                                  # no such webhook PaC can receive; use `tag`
                                  # instead (publishing a Release also creates the
                                  # tag).
      branch: main                # Exact branch name, OR a glob like "release/*".
                                  # Used by push/pull_request (via PaC's own
                                  # on-target-branch matcher) AND by branch.created
                                  # (via a CEL regex the platform builds for you).
      branchPattern: ""           # Accepted by the schema but not currently
                                  # consumed anywhere in the platform - use `branch`
                                  # (it already accepts glob patterns) instead.
      tagPattern: "v[0-9]+\\.[0-9]+\\.[0-9]+"   # REQUIRED when event is "tag". A
                                  # regex (not a glob) the pushed tag name must
                                  # match. Backslash escapes are handled correctly.
      filePathPattern: ["api/**"]  # Optional. Only trigger when a changed file
                                  # matches one of these globs. push events only.
      labels: ["preview"]        # Optional, pull_request events only. Only trigger for
                                  # a PR carrying ALL of these GitHub labels (same
                                  # all-must-match semantics as ArgoCD's own pullRequest
                                  # generator `labels:` field - see
                                  # ephemeralEnvironments.pullRequest above). Omit for no
                                  # label filter (fires on every PR touching this
                                  # branch). If you're building an image for
                                  # ephemeralEnvironments.pullRequest to deploy, match
                                  # this to that same label or you'll build on every PR,
                                  # not just ones that actually get an environment.
                                  #
                                  # You normally don't need to write a pipelines.pr-build
                                  # entry at all: setting ephemeralEnvironments.
                                  # pullRequest.enabled: true is enough on its own - the
                                  # platform synthesizes exactly this flow shape
                                  # automatically (branch reused from your push flow,
                                  # labels reused from ephemeralEnvironments.pullRequest.
                                  # labels). Only declare pipelines.pr-build yourself to
                                  # override that default - see
                                  # ../admin/ephemeral-environments.md.
    steps:
      - stage: build              # REQUIRED per step. One of build/test/deploy/
                                   # release. build, if present, must be the flow's
                                   # first step - nothing chains into it.
        env: dev                  # Required for test/deploy/release steps; not
                                   # valid on build. Which environment this step
                                   # targets/exercises.
        testName: integration      # Only meaningful on a test step with role test or
                                   # execute - see the test: block above. Not called
                                   # `name` - see below. Not valid with role check-ride.
        role: test                 # Only on a test step. test (default): your own
                                   # glidepath/<testName>.yaml. execute: an action, not
                                   # a test (a cache flush), same file, no test evidence
                                   # expected. check-ride: the standard the release
                                   # depends on - runs <app>-check-ride from the shared
                                   # check-rides repository, takes no testName, at most
                                   # one per flow. See ../admin/testing.md.
        required: true             # Only on a test step, default true. false: a failed
                                   # run is recorded but the flow continues. Not valid
                                   # with role check-ride.
        timeout: 10m               # Only on a test step, default 10m (Ns/Nm/Nh): how long
                                   # the stage waits for the run; past it the outcome is
                                   # `timeout` and the step fails.
        cluster: prod-cluster     # Only valid on a release step. Optional - only set
                                   # this if you want an explicit consistency check
                                   # against deploy.environments' own cluster for
                                   # this env (they must agree); normally the cluster
                                   # resolves from there and this can be omitted. See
                                   # docs/multi-cluster.md.
        name: my-label            # Optional free-text label, passed through to the
                                   # generated PipelineRun's params. Cosmetic today.
                                   # A different field from testName above - this one
                                   # exists on every step, testName only matters on a
                                   # test step.

Checks-only repositories

A repository that holds configuration and ships nothing itself (a team’s Autopilot agent definitions, policy, docs) leaves build out. This is the whole file:

apiVersion: platform/v1
kind: PipelineConfig
# No build: a checks-only repository. Glidepath runs its PR checks and nothing else.

What it gets, the same as every other onboarded repository: its registration (the app-<name>-cicd namespace, the Pipelines-as-Code Repository, pipeline-runner), and the always-on .tekton/ files: onboarding-resync.yaml, pull-request-cicd-validate.yaml, pull-request-agent-gates.yaml, pull-request-agent-definitions.yaml, pull-request-agent-preflight.yaml, and push-agent-release.yaml (the last three run only when agents/ changes, ../admin/agent-definitions.md).

What it does not get: no flow-*.yaml, no build cache, no environments or glidepath/ scaffolding, no gitops repo or release, no Argo CD Applications.

Without build, pipelines, test, deploy, api and ephemeralEnvironments are refused (GP-CHECKS-001; the schema’s dependencies, so stage-preflight and the chart refuse them too). governance, notifications and secrets are allowed. Adding build (and a flow under pipelines) later is an ordinary change: the next resync delivers the flows and the repository becomes an app.

Check it before you push

tools/glidepath-validate cicd.yaml (in the Glidepath repo) runs the same schema the pipeline uses, plus the environment and flow rules the pipeline would otherwise only reject partway through a run, and names each problem’s field and rule. See ../admin/contract.md.

Design rules this file follows

  • Read fresh, every run, from the triggering commit. preflight (always the first Task in every Pipeline) reads cicd.yaml straight out of the cloned workspace and validates it on the spot. There is deliberately no ConfigMap sync, no cache, no second copy anywhere - a cicd.yaml change takes effect on the very next push, full stop.
  • Fixed superset DAG, not arbitrary graphs. pipelines: toggles and orders a known, finite stage set (build, test, deploy, release). It cannot express an arbitrary DAG - that would require compiling a bespoke Pipeline per app, a meaningfully heavier engineering commitment than is justified until there’s real evidence apps need it.
  • governance toggles are honest about being real, not stubs. Setting governance.sast: true runs a real Semgrep scan - see features.md and ../admin/governance-stubs.md for exactly what each gate verifies.

Two build strategies: build.script, or a multi-stage Containerfile

build.script (e.g. ./build.sh) is optional, not required. Two ways to structure a build, pick whichever fits:

  • Script + thin Containerfile (the common shape - see examples/02-standard-ci.yaml): build.script does the actual build (npm ci && npm run build, mvn package, …) inside the resolved build.agent image, and build.containerfile becomes a thin packaging step that just copies the already-built artifacts. This runs as its own Tekton Task (build-source), concurrently with unit tests.
  • Everything in a multi-stage Containerfile (see examples/08-containerfile-only-build.yaml): omit build.script entirely and do the whole build inside build.containerfile (FROM ... AS builder / RUN npm ci && npm run build / COPY --from=builder). The build-source Task is skipped entirely and BuildKit builds the Containerfile directly. Build caching in this path comes from BuildKit’s layer cache (kept in a <image>-buildcache package next to your image), not build.cache - order your Containerfile’s COPY package*.json / RUN npm ci before copying the rest of the source, or the cache invalidates on every source change regardless of whether dependencies changed. If you build for both architectures, compile in a FROM --platform=$BUILDPLATFORM stage - see Multi-architecture images.

build.agent is required either way - unit tests always run inside it, independent of which build strategy you pick.

Version metadata inside the image (HANGAR_VERSION/HANGAR_GIT_REVISION)

Every build-image invocation passes two build args to your Containerfile automatically, regardless of build strategy - no cicd.yaml field turns this on, it’s always available:

  • HANGAR_VERSION - the same version build-image resolved for the image tag (from a git tag, package.json, pom.xml, pyproject.toml, or a VERSION file, in that order - see “How the image tag is resolved” below).
  • HANGAR_GIT_REVISION - the full git commit SHA being built.

Declare an ARG for whichever one you want and use it however your language needs - nothing consumes these automatically, so an app that doesn’t declare the ARG sees no effect at all (BuildKit ignores a build arg with no matching ARG). The motivating case is Go, which has no manifest-embedded version convention the way Node/Python/Maven do:

FROM golang:1.24-alpine AS build
ARG HANGAR_VERSION
ARG HANGAR_GIT_REVISION
RUN go build -ldflags "\
      -X example.com/payment-api/version.Version=${HANGAR_VERSION} \
      -X example.com/payment-api/version.Commit=${HANGAR_GIT_REVISION}" \
      -o /out/payment-api ./cmd/payment-api

The same two args are also set as this image’s org.opencontainers.image.version and org.opencontainers.image.revision OCI labels (readable via crane config/docker inspect with no Containerfile changes needed) - the build-arg path is only for baking version info inside the binary itself, which the OCI labels alone can’t do.

Multi-architecture images ($BUILDPLATFORM)

build.platforms defaults to both arm64 and amd64, and the build node is arm64, so the amd64 image’s RUN steps run under QEMU emulation - slow, and the Go compiler crashes under it (known-gaps #37). build-image uses BuildKit, which lets a stage run on the build node’s own platform and cross-compile for the target instead:

FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o app .

FROM alpine:3.22
COPY --from=build /app/app /app
CMD ["/app"]

Only the build stage needs --platform=$BUILDPLATFORM; the final stage stays on the target platform. This works for anything whose output doesn’t depend on the build machine’s architecture: Go, a Java jar (mvn package in a $BUILDPLATFORM Maven stage, copied into a target-platform JRE stage), a Node or Python app with no native modules. A stage that must run target-platform binaries (installing native npm modules, apk add in the final stage) still emulates. The script path avoids the question entirely: build.script runs natively and the Containerfile only copies its output.

The image is published as one OCI image index under the real tag. There are no -arm64/-amd64 tags (kaniko builds published those until 2026-10-17).

Additional artifacts (build.artifacts)

Optional, empty by default - most apps don’t need this. Publishes one or more additional non-container artifacts from the same build, alongside (not instead of) the primary container image:

build:
  agent: go-1.24
  artifacts: [go-binary]
build:
  agent: python-3.12
  artifacts: [python-wheel]

Each declared type builds inside your resolved build.agent image (python -m build --wheel for python-wheel, go build for go-binary, linux/amd64 only today) and publishes as a real, pullable OCI artifact - not a container image, but a real object in the same registry - at <image-repo>:<version>-<type> (e.g. ghcr.io/acme/payment-api:1.8.3-go-binary), using the same registry-credentials your container image already pushes with. No separate publish target or credential to configure. Pull it with oras pull <ref>.

The version always matches the container image’s own resolved version exactly (both are read from the same build-image result) - there’s no separate version-resolution path to drift out of sync.

Build dependency caching (build.cache)

Only applies to the build.script path above - the multi-stage-Containerfile path caches via BuildKit’s layer cache instead (see above). Opt in with:

build:
  agent: nodejs-20
  script: ./build.sh
  cache:
    enabled: true
    size: small   # small=1Gi, medium=2.5Gi, large=5Gi, xlarge=8Gi - default small

No type field - it’s derived from build.agent’s prefix (nodejs-* -> npm, or yarn when the repo has a yarn.lock and no package-lock.json; openjdk-* -> Maven), not a second, separately-declarable value that could disagree with agent. Agents without cache support yet (python-*, go-1.22) treat enabled: true as a no-op (logged, not an error).

What’s actually cached is the build tool’s own download cache (npm’s tarball cache, yarn’s package cache, Maven’s local repository) - not node_modules/target directly, since npm ci deletes and rebuilds node_modules from scratch by design. The cache is a real, persistent, per-app PVC with one shared directory per tool (npm/, yarn/, maven/). It is deliberately not keyed by a lockfile hash: none of the three tools can serve a stale package from its own download cache (npm’s cache is content-addressed by the integrity hash in your lockfile, yarn stores one file per package@version, Maven’s local repository is laid out per exact version), so a dependency bump only fetches the packages that actually changed instead of starting from an empty directory. The directory only grows as versions are added; delete the PVC to reset it.

The same npm/yarn cache directory is mounted into the unit-test step (run-tests), so test.sh’s own npm ci reuses the tarballs build.sh just fetched - the two run concurrently, which is safe for npm and yarn. Maven is excluded there: its local repository isn’t safe for concurrent writers, and serializing the test step behind the build step to protect it would cost more than the download it saves.

Not resizable live after onboarding - changing size later means recreating the PVC (losing its content).

Build source volume (build.sourceVolume)

Every stage’s PipelineRun clones the repo into a source Tekton workspace - the checked- out tree, build.script’s own output (dist/, target/, …), and the image build context all live there. For most apps the default is plenty; a large monorepo-style repo or a build that produces a lot of intermediate output can fill it, which shows up as a build failing with no space left on device rather than any error from your own build script. Bump it with:

build:
  agent: nodejs-20
  script: ./build.sh
  sourceVolume:
    size: medium   # small=2Gi, medium=5Gi, large=10Gi, xlarge=20Gi - default small

This is a different volume from build.cache above - sourceVolume is the ephemeral, per-run working directory (a fresh volumeClaimTemplate-backed PVC every PipelineRun, torn down when the run completes), while build.cache is the persistent, per-app dependency-download cache that survives across runs. Sizing sourceVolume up doesn’t help a slow dependency install; sizing build.cache up doesn’t help a build running out of disk. One sourceVolume.size applies to every stage’s source workspace for the app (build/test/deploy/release), not just build’s - simpler than a per-stage knob, and build is normally the only stage large enough to need it anyway. Since it’s a fresh PVC every run, there’s nothing to migrate: a size change just takes effect on the next PipelineRun.

The test: block

test:
  enabled: true       # default true
  name: integration   # falls back to nothing - see "How flows work" below, one of
                       # test.name or a step's own `testName` must be set

test.name is the app-wide default TestWorkflow name, inherited by every test step in every flow that doesn’t set its own testName. enabled: false skips the stage’s actual test run but the stage still runs (span, CDEvent, Slack notification) - it just reports success without having tested anything.

A test step also has a role (steps[].role, see the pipelines: reference above and ../admin/testing.md): test (the default) and execute both run the workflow named here, and differ only in intent (an execute step is an action, not a test, and required: false makes it best-effort); check-ride runs <your-app-name>-check-ride from the shared check-rides repository instead - the workflow a release depends on, written by your team with an SDET against a template product owners and SDETs own, synced into Testkube by Argo CD and never applied from this repo. The rest of this section is about the test/execute workflow in this repo.

Every run, whatever its role, records the same result set (outcome, Testkube execution id, workflow digest, test counts from your JUnit report) as Task results, and Tekton Chains attaches that stage’s provenance to the tested image, so the release carries the evidence - ../admin/testing.md has the full contract.

Whatever name is in effect resolves to one of two things, checked in this order:

  1. glidepath/<name>.yaml (Testkube, preferred) - if this file exists, it’s applied as a Testkube TestWorkflow and run for real. It doesn’t have to be a test in the strict sense - a TestWorkflow is just “a thing that runs and reports pass/fail,” so glidepath/<name>.yaml is equally at home clearing a cache or running some other ops action before release as it is running assertions against your code. Testkube CE installs into one shared testkube namespace on this cluster (not one per Application - cross-namespace execution is a Testkube Pro/Enterprise-only feature), so two things about this file are enforced by the platform, not left to convention:
    • metadata.name is always overwritten to <your-app-name>-<name> before applying, regardless of what you put there - required so TestWorkflow names stay unique across every onboarded Application sharing that one namespace.

    • To reference your own app-secrets in the workflow’s env, use the literal secretKeyRef.name value __APP_SECRETS_NAME__ - it’s substituted for your Application’s real, per-Application secret name in that shared namespace before applying. Don’t hardcode a real secret name; it won’t exist under that name.

      env:
        - name: API_TOKEN
          valueFrom:
            secretKeyRef: { name: __APP_SECRETS_NAME__, key: api-token }

      Whatever’s in this namespace’s own app-secrets (the secrets: block earlier in this file) is what shows up there - same keys, materialized fresh into Testkube’s namespace immediately before each run and blanked again immediately after. A Kyverno policy in the testkube namespace enforces that secretKeyRef.name here can only ever resolve to your own Application’s secret, not another tenant’s - see ../admin/adr/0008-kyverno-testkube-secret-policy.md.

    • The platform’s context reaches the workflow as config (2026-10-19). Declare any of these keys under spec.config and the test stage passes the value on every run ({{ config.<key> }} in the spec); keys you don’t declare are not passed, so a workflow that declares none runs exactly as before:

      key value
      app this Application’s name
      env the test step’s env
      image the image reference just built and deployed (<repo>:<version>-<sha>)
      url the in-cluster address of that environment’s Service, http://<app>.<type>-<app>-<env>.svc.cluster.local:<port> (port 8080 unless the pipeline identity may read the Service)
      revision the commit under test
      gitUrl this repo’s clone URL
      chain the flow’s chain id, the same one every PipelineRun of this flow carries

      The same values (minus url/gitUrl) are set as execution tags on every run, so a run can be found again by what it tested (testkube get twe --tag image=..., or Testkube’s tagSelector). A good workflow hardcodes no environment: it reads config.url, and if its tests ship inside the image (a test/ folder the Containerfile copies in) it runs them with container.image: "{{ config.image }}", which pins the suite to the build it checks and needs no clone of a private repo. Every applied workflow gets registry-credentials added to spec.pod.imagePullSecrets, so that private image pulls.

    • The environment’s own NetworkPolicy (airframe-application) admits same-namespace pods only. For a workflow to reach the Service, the app’s glidepath/base.yaml needs networkPolicy: {allowIngressFrom: [{namespace: testkube}]} - a per-app opt-in today, not a platform default.

    • Write JUnit to a path your workflow lists under a step’s artifacts.paths; Testkube parses it into the execution record (per-test pass/fail), which is what Tower will read. See the TestWorkflow CRD docs (docs.testkube.io) for the rest of spec: - image, steps, services, retries, etc. are all standard Testkube, nothing platform-specific beyond the rules above.

  2. ./integration-test.sh (fallback) - if there’s no glidepath/<name>.yaml, this runs instead, in your own build.agent image. Whatever name is in effect, plus the step’s env and the image reference under test, reach it as TEST_NAME/TEST_ENV/IMAGE_REF environment variables. This is the original, pre-Testkube mechanism - kept working for Applications that haven’t added a glidepath/<name>.yaml yet, not the recommended path for a new Application.

Neither present: the stage fails (since 2026-10-19; it used to report success, which made a typo in test.name look like a passed test). enabled: false is the one way to run the stage without a test; it then reports skipped and succeeds.

The stage waits up to 10 minutes for the run to finish (test-timeout on the pipeline; a per-step field in this file is planned). Past that the outcome is timeout and the stage fails; the Testkube execution itself keeps running until the workflow’s own timeouts end it.

The api: block

api:
  spec: openapi.yaml   # default: openapi.yaml at the repo root
  type: openapi        # default: openapi (or asyncapi)

Every build reads api.spec from the checkout and attaches it to the pushed image as a signed attestation (predicate type https://hangar.io/attestations/api-spec/v1, keyless like the SBOM). Each version you deploy then carries the exact spec it was built with: Tower shows the spec per environment by the image digest that environment runs, and the api-compat release check compares a release’s spec with the one already running where it is going.

The block and the file are both optional. With no file at spec the build logs that the app publishes no API and carries on; a file that is not YAML/JSON, or lacks the top-level openapi:/asyncapi: field type names, is reported as invalid, and a signing failure as failed. None of these fail the build. The attestation holds the spec as JSON (YAML is converted), its sha256, info.title and info.version; rebuilding the same digest never attaches a second copy.

Keep the file in step with the code: nothing yet checks the spec against the routes the app really serves, so a stale spec produces wrong diffs.

The api-compat release check, what it reports and how an owner accepts a breaking change: Glidepath docs/admin/api-compat.md.

The deploy: block

deploy:
  strategy: rollout             # default; the only strategy actually implemented -
                                 # every deploy provisions an Argo Rollout.
  environments:                  # default: one Ground environment, dev
    - { name: dev, tier: ground }

environments (below) is not the thing that decides where a flow deploys - that is each deploy/release step’s own env: under pipelines: (below). A deploy step’s env must be listed in environments, and a release step’s env must be a flight one - either is a preflight rejection before anything runs, rather than failing later (a bare Forbidden deep inside the deploy Task, in the deploy case).

For deploy, the list provisions RBAC (the pipeline runner’s read access to the Rollout, per environment) - deploy always stays on this cluster. A flight environment can set cluster, naming a different physical cluster its ArgoCD Application lives on (resolved against the control-plane chart’s own cluster registry) - see multi-cluster.md for how the release PR delivery and ArgoCD feedback path differ for such an environment.

environments - the environments, defined once

deploy.environments declares every environment once, in promotion order (ADR-0019). It replaced lowerEnvironments, upperEnvironments and promotionOrder, which were removed on 2026-10-07 after every app moved; preflight and the chart now refuse them with a pointer here.

deploy:
  environments:                       # the order here is the promotion order
    - { name: dev,     tier: ground }
    - { name: test,    tier: ground }
    - { name: staging, tier: flight, cluster: example-prod }
    - { name: prod,    tier: flight, cluster: example-prod, production: true }
Field Meaning
name Lowercase letters, digits and -, starting with a letter, at most 31 characters; unique.
tier ground: deployed automatically on every push. flight: deployed only through a release PR and its guardrails (a release pin PR for a cloud target, ADR-0020).
cluster Flight only, and only when the environment runs on another cluster than the app’s own. Must be a cluster in the platform registry. A Ground environment cannot set it yet (a future feature).
production true for the environment serving real users and real data. Must be a Flight environment, and (Kubernetes targets) its cluster must be an upper cluster: one nothing below it can write to, changed only through reviewed merges (ADR-0024).

The list order is the promotion order: Tower promotes from each environment to the next, and a release names the environment it came from by it.

Per-environment cloud resources

A cloud app deploys to the resource named under deploy.<target> (lambda, ecs or azureContainerApps). An environment can override fields of that block, so dev and test deploy to different functions, services or Container Apps. The override is partial: fields it does not set come from the app-level block.

deploy:
  target: aws-lambda
  lambda:
    functionName: orders-fn          # the default, used by dev
    region: us-east-1
  environments:
    - { name: dev,  tier: ground }
    - { name: test, tier: ground, lambda: { functionName: orders-fn-test } }
    - { name: eu,   tier: ground, lambda: { functionName: orders-fn-eu, region: eu-west-1 } }

An environment may only override the block for the app’s own target (a lambda override on an ECS app is refused), and the target itself is per app, not per environment. The deploy step for environment test resolves to orders-fn-test in us-east-1. The resources must already exist, as before: Glidepath only updates what is there (an Airframe AwsLambdaTarget / AwsEcsTarget per environment creates them). There is no shared credential: each environment’s deploy assumes the deploy role of its own function or service (cloud-deploy-identity.md).

chart - the Helm chart your environments render

Unset (every app today), each environment renders the cluster’s default chart, Airframe’s airframe-application at the version the platform team pins in the cluster’s cluster-defaults.yaml. Set deploy.chart to pin another version or your own chart, and an environment’s chart to change one environment only (a canary of a new chart version):

deploy:
  chart:                                   # app-wide; fields not set come from the cluster default
    repoURL: https://github.com/<owner>/<repo>
    path: charts/<chart>                   # a git source; or `chart: <name>` for a Helm/OCI registry
    targetRevision: v1.2.3
  environments:
    - name: dev
      tier: ground
      chart: { targetRevision: v1.2.4 }    # this environment only, merged over deploy.chart

Precedence, highest first: the environment’s chart, then deploy.chart, then the cluster default. Pinning a newer Airframe chart is just targetRevision. A chart from another source needs repoURL, targetRevision, and path (git) or chart (registry), never both (it inherits nothing from the default), and must meet the chart contract. Only a Kubernetes app (target: k8s-rollout) has a chart. Design: ADR-0023. The field is accepted and validated now; environments start rendering it as ADR-0023’s later slices land (Ground environments in slice 3, Flight in slice 4).

target - deploying somewhere other than this cluster

Optional, defaults to k8s-rollout - every existing app is unaffected. Set deploy.target: aws-ecs to have the deploy stage update an ECS service directly instead of provisioning an Argo Rollout on this cluster:

deploy:
  target: aws-ecs
  ecs:
    cluster: my-ecs-cluster
    service: my-service
    containerName: my-app        # must match a real container name in the current
                                  # task definition - checked, not assumed
    region: us-east-1            # default
    # taskDefinitionFamily: ""   # defaults to "<app-name>-<env>"

An Airframe AwsEcsTarget creates the cluster, the service and its first task definition, and its status.cicd carries exactly this block. deploy-ecs.yaml copies the built image into the service’s ECR repository (Fargate can only pull a private image from there), registers a new task-definition revision with that ECR digest, updates the named service to it, and waits for ECS’s own stability check - nothing is deployed via this cluster’s ArgoCD/Rollout machinery for this target.

Credentials: none to set. deploy-aws-ecs runs as the ServiceAccount glidepath-cloud-deployer and assumes glidepath-deploy-ecs-<service>, the IAM role the AwsEcsTarget creates for the service: push to its ECR repository, register task definitions, update this service and pass its execution role to ECS, nothing else. Until 2026-10-16 an app listed aws-access-key-id/aws-secret-access-key; those entries are now ignored and can be deleted. How it works: cloud-deploy-identity.md.

aws-lambda

deploy:
  target: aws-lambda
  lambda:
    functionName: my-function
    region: us-east-1           # default

Updates an existing Lambda function’s container image and waits for the update to complete.

Credentials: none to set. There is no AWS secret for a Lambda deploy. deploy-aws-lambda runs as the ServiceAccount glidepath-cloud-deployer and assumes glidepath-deploy-<functionName>, an IAM role that Airframe’s AwsLambdaTarget creates for your function and that may only push to its repository and update its code. AWS grants it for an hour in exchange for a token Kubernetes signs for the run. So the function needs an AwsLambdaTarget, and the fleet’s clusters.yaml must name the AWS account (your namespace then has the ConfigMap glidepath-cloud-identity); the deploy fails with a message naming whichever is missing. Until 2026-10-16 an app could list aws-access-key-id/aws-secret-access-key instead; those entries are now ignored for Lambda and can be deleted. How it works: cloud-deploy-identity.md.

Lambda can only pull a container image from Amazon ECR, never from ghcr.io, where this pipeline builds, signs and scans everything. So the deploy stage copies the image for you:

  1. It reads the function’s architecture and the ECR repository its current image lives in (so there is no repository field to set; the function must already exist with an ECR image).
  2. It copies the matching per-architecture image (<tag>-arm64 or <tag>-amd64) into that repository with the role and checks the digest is unchanged. Lambda does not accept a multi-architecture image index, so the function’s architecture decides which one is copied. Your build.platforms must include it (an app that built only amd64 cannot deploy to an arm64 function, and the stage says so).
  3. It updates the function to that ECR digest and waits for the update to finish.

Glidepath never creates the function or its ECR repository: the AwsLambdaTarget does, with a placeholder image, before the first deploy. Signatures and attestations stay on ghcr.io (the digest is the same, so they remain verifiable there). An app that already builds to ECR skips the copy.

azure-container-apps

deploy:
  target: azure-container-apps
  azureContainerApps:
    resourceGroup: my-resource-group
    appName: my-container-app

secrets:
  - name: azure-client-id
  - name: azure-client-secret
  - name: azure-tenant-id

Updates an existing Container App’s image to a new revision and polls it for a healthy state. The three secrets: entries are an Azure AD service principal (az login --service-principal) - scope its role to just this Container App if possible. The Container App must already exist, including any private-registry pull credentials it needs (configured on the resource itself, out of band) - this never creates the app.


All three cloud targets resolve correctly even for a git-rooted deploy (a tag push with no build/test in that same run), not just the normal build → test → deploy flow - resolve-deploy-target clones just cicd.yaml directly when it has no upstream config to inherit, and every cloud-target Task reads that same resolved config rather than re-deriving it. None of the three are live-verified - no AWS or Azure account exists in the environment this was built in. See known-gaps.md.

Multi-stage pipeline flows

The pipelines: field enables declarative control of multi-stage, self-chaining pipelines. Rather than configuring individual legacy triggers, teams declare named flows with a root trigger source and a sequence of stages. Each flow runs the corresponding catalog Pipeline (build, test, deploy, release) - no custom Pipelines or DAG configuration needed.

Editing pipelines: has two independent effects, not one - easy to forget: changing cicd.yaml’s pipelines: section and pushing it does NOT, by itself, change which event-chained Triggers exist in the cluster. That only happens via helm upgrade of the app chart picking up the new cicd.yaml as its values file - see ../admin/onboarding-mechanics.md. The git push side (delivering the updated git-rooted .tekton/*.yaml PipelineRun definitions) is fully automatic; the cluster-side Trigger/TriggerBinding/TriggerTemplate objects are not - re-run helm upgrade yourself after any pipelines: edit that adds, removes, or reorders event-chained steps, or the new stages simply won’t fire (the git-rooted root stage still will, since that part IS automatic, which is what makes this easy to miss).

How flows work

Two entirely different mechanisms fire a flow’s steps, depending on position:

flowchart TD
    push["git push / PR / tag / new branch"]

    subgraph mechanism1["Mechanism 1: a flow's FIRST step only"]
        pac["Pipelines-as-Code\nmatches .tekton/flow-&lt;name&gt;.yaml\nagainst the incoming webhook"]
        pr1["Creates the PipelineRun\ndirectly - no broker involved"]
        pac --> pr1
    end

    subgraph mechanism2["Mechanism 2: every step AFTER the first"]
        cdevent["Previous stage's finally block\nemits a CDEvent\n(artifact.published, testcaserun.finished, ...)"]
        broker["In-cluster CDEvents broker\n(Tekton Trigger, CEL-filtered\nby app-namespace + event type)"]
        pr2["Creates the PipelineRun"]
        cdevent --> broker --> pr2
    end

    push --> pac
    pr1 -. "on completion, emits its OWN\nCDEvent, feeding mechanism 2\nfor the NEXT step" .-> cdevent

    style mechanism1 fill:#e8f4fd,stroke:#4a90d9
    style mechanism2 fill:#fef3e2,stroke:#d9954a

deliver-onboarding-files.yaml generates one .tekton/flow-<name>.yaml file per flow for mechanism 1, driven by your cicd.yaml’s pipelines: section - see ../admin/onboarding-mechanics.md#keeping-onboarding-boilerplate-in-sync. Mechanism 2 is what helm upgrade provisions, per the “two independent effects” warning above.

Trigger sources: The root trigger can be git (pushed webhook from Pipelines-as-Code, for build/release/deploy/test stages) or event (CDEvent broker, not currently a supported way to configure a flow’s first step - only used internally). Subsequent stages are always event-chained: each stage’s completion emits a CDEvent that the next stage listens for.

Event chaining rules - keyed by whichever stage the step actually follows, not by the step’s own identity, so any of these pairings works in any combination (confirmed live, including the less obvious release → test case):

flowchart LR
    build -- "artifact.published" --> test
    test -- "testcaserun.finished\n(outcome: Succeeded)" --> deploy
    deploy -- "service.deployed" --> release
    release -- "change.created" --> next["any stage again -\ne.g. release chained\nINTO a later test step"]

The event a step listens for is determined entirely by whichever stage the previous step in your steps: list actually is - not by the new step’s own identity. That’s why release → test (an unusual-looking order) works exactly the same way as build → test: flow-triggers.yaml looks up the event type from the previous step’s stage name, not from a fixed build→test→deploy→release sequence.

  • build → next: triggered by artifact.published (image built and pushed)
  • test → next: triggered by testcaserun.finished with outcome: Succeeded (test is the one stage whose domain event fires unconditionally, pass or fail - every other stage’s event is already gated at the source, so only this transition needs the outcome check)
  • deploy → next: triggered by service.deployed
  • release → next: triggered by change.created

Tracing across stages: Each flow gets a chain-id and OpenTelemetry traceparent at the root stage, threaded through all subsequent stages via CDEvent payloads. The flow-root span covers the entire automated pipeline execution (all stages’ durations), not including human review/merge time.

Chaining order is positional, full stop. There is no after: field - an earlier draft had one, but nothing ever read it (chaining was always determined by a step’s index in the steps: list, looking at whichever stage the previous entry declares), so it was pure decoration that could silently disagree with what actually ran. It was removed rather than left in place. Write your steps in the order they execute.

Every test step needs env and a resolvable test name. env says which environment’s build this run is exercising (there’s no default - a test result is meaningless without knowing what it ran against). The test name can come from either the step’s own testName, or the top-level test.name shared by every test step in the app - one of the two has to resolve, checked by preflight before anything runs. The step-level override only matters once a flow has more than one test step (the two-step case: hitting the same build with two different TestWorkflows) - otherwise just set test.name once and every test step inherits it. Both env and the resolved test name, along with the image reference under test, reach your own ./integration-test.sh as TEST_ENV, TEST_NAME, and IMAGE_REF environment variables.

A deploy step’s env must be provisioned. It has to be listed in deploy.environments (see above) - that’s the list the pipeline runner is granted access into. Declaring env: staging on a deploy step without also listing staging under deploy.environments is rejected at preflight time, instead of failing later with a bare Forbidden RBAC error deep inside the deploy Task.

What starts a build

A push to the flow’s branch starts it, with one exception: a push that changes only cicd.yaml, files under glidepath/ (environment values, release pins) or files under .tekton/ (the generated pipeline files) builds nothing. Editing your pipeline config, an environment’s values or merging an onboarding re-sync PR changes what runs next, not the code, so it doesn’t rebuild. A cicd.yaml change that adds a flow doesn’t start it either; the flow runs on the next push that matches it. If a push mixes those files with a code change, it builds.

The one push of that kind that does build is the merge of your app’s first onboarding PR (the one that adds .tekton/onboarding-resync.yaml): it starts the app’s first build, so a new app builds and deploys without anyone pushing code. To build again without a code change, push an empty commit (git commit --allow-empty -m "rebuild").

Event-chained flows (downstream chaining)

Stages after the root are always event-chained - they’re triggered by CDEvents emitted by the previous stage, not by new git events. This is automatic: when you declare a multi-stage steps: list, the platform wires up the event triggers for you (after a helm upgrade - see above).

The only exception is if you omit a stage from the flow - say, you configure build → deploy with no test stage. In that case, deploy still waits for artifact.published from build; the platform doesn’t create a path for build to directly trigger deploy. If you need to skip stages conditionally, use the app’s own cicd.yaml to enable/disable stages, not the pipelines flow structure.

Migration from legacy list form

Earlier cicd.yaml files used a list form for the pipelines: field:

# Legacy (still supported) list form
pipelines:
  - task: build
    trigger: push
    branch: main
  - task: test
  - task: deploy
    triggerEnv: dev

This is automatically normalized to the new object form internally. The new form is preferred for clarity, especially when naming flows or using event-based triggers.

Local validation before pushing

Run yajsv -s schemas/cicd.schema.json <(yq -o=json . cicd.yaml) locally (same tool preflight uses) to catch schema errors before a push burns a pipeline run finding them for you.

Real bugs found in this mechanism, fixed - worth knowing about if something looks wrong

enabled: false silently ineffective (fixed). build.unitTest.enabled and test.enabled both used to be read via jq -r '.path.enabled // true' - jq’s // operator treats false the same as null/missing, so an explicit enabled: false was silently overridden back to "true". No Application had ever actually been able to disable unit tests or the test stage via cicd.yaml until this was found and fixed. Verified live: unitTest.enabled: false now genuinely skips the test command.

Tests never actually ran, for anyone, ever (fixed). A separate, deeper bug in the same area: the test stage’s Task (run-integration-tests.yaml at the time, since renamed to run-testworkflow.yaml) had its internal result-passing write a value with a trailing newline that a cross-step variable substitution doesn’t strip ("true\n" instead of "true"), so its own enabled-check always evaluated false regardless of the real config. Every test run silently reported “disabled… skipping.” Fixed and confirmed live - a real test command now genuinely runs.

branch.created’s branch pattern used to be ignored entirely (fixed). The CEL filter generated for a branch.created trigger never consulted trigger.branch at all

  • any newly created branch fired the flow, regardless of the pattern you set. Confirmed live both before (fired on a non-matching branch) and after the fix (correctly filtered: a matching preview/x branch fired it, a non-matching one didn’t).

None of the above require anything from you - they’re platform-side fixes, documented here so a cicd.yaml that looked like it “should” have worked before now actually does.