> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ankra.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# pipeline.yaml reference

> Every key of .ankra/pipeline.yaml with its type and default, the expression language and its contexts, the trigger table with the reason a run records, and the diagnostics the validator reports.

`.ankra/pipeline.yaml` is the one file an [Ankra pipeline](https://docs.ankra.ai/guides/ankra-pipelines.md) is planned from. It sits beside [`.ankra/ankra.yaml`](https://docs.ankra.ai/reference/application-descriptor) in the repository, and its parser, validator and planner live in exactly one place on the platform, so the dry run you get from `ankra pipeline validate` is the run.

On GitHub, a run plans its *logic* from this file as committed at the run's own head sha; on GitLab and Bitbucket Cloud it plans from the definition stored through `ankra pipeline definition put`. A dispatch may also carry a definition of its own (`ankra pipeline run --spec-file`, `spec_yaml` on the API): it is stored verbatim on the run, and the planner and the dispatcher both render from it. A run's *authority* - its [protected sections](https://docs.ankra.ai/guides/ankra-pipelines.md#logic-is-open-authority-is-closed) - always comes from the default branch's approved definition, whichever source the logic came from, an override included.

## The document

```yaml theme={null}
apiVersion: ankra.io/v1
kind: Pipeline
metadata:
  name: "orders-api"
stages:
  - name: "test"
    kind: "run"
    image: "golang:1.26"
    run: "go test ./..."
```

The parser is lenient about anything that does not change what the pipeline does, and strict about anything that would:

* Unknown keys are ignored, and reported. A key this reference does not define - at the top level or in any block - decodes into nothing, so the pipeline runs on whatever the default is; `validate` answers `warn` rather than `ok` and names the key, the line and the field it was probably meant to be (`unknown key "timeuot" in the stage block; did you mean "timeout"?`). The key space of `env`, `with`, `outputs`, `permissions`, `matrix` axes and your own service and environment names is yours, and is never reported. Ignoring the key is what lets an Ankra older than your file keep reading it, so this stays a warning and never blocks a run.
* A single scalar stands in for a one-element list (`branches: main`), a quoted number reads as a number (`retention_days: "30"`), and a flag accepts `true`/`false`, `yes`/`no`, `on`/`off` and `1`/`0`. YAML anchors are resolved, so an anchored `services` or `defaults` block behaves like an inline one.
* An empty document, a document that is not YAML, an `apiVersion` other than `ankra.io/v1`, a `kind` other than `Pipeline`, or a `stages` block that is not a list is fatal: the pipeline cannot be planned.
* `metadata.name` and at least one stage are required.

A pipeline mistakenly committed under `.ankra/manifests/` is refused by the manifest gate, because the file carries a `kind` and an `apiVersion` and would otherwise be applied to a cluster.

## Top-level keys

| Key | Type | Default | Meaning |
| - | - | - | - |
| `apiVersion` | string | required | `ankra.io/v1` |
| `kind` | string | required | `Pipeline` |
| `metadata.name` | string | required | The pipeline's name |
| `on` | block | none | The triggers - see [Triggers](#triggers). A pipeline that declares none is still runnable by API dispatch. |
| `full_run` | block | none | `events` and `branches` naming the runs on which no path filter applies, so every stage runs. See [Setting path filters aside](#setting-path-filters-aside). |
| `concurrency.group` | string | none | Runs sharing a group serialise: a new run waits queued behind the group's holder. Expressions are allowed, for example `"${{ ankra.repository.provider }}:${{ ankra.repository.owner }}/${{ ankra.repository.name }}-${{ ankra.ref }}"`. |
| `concurrency.cancel_in_progress` | flag | `false` | With a group, an in-flight run of the same group is superseded and cancelled when a new one arrives. Leave it off and the new run waits instead - on a per-branch group that means the branch queues behind itself, which the validator points out as advice; see [Newest commit wins](https://docs.ankra.ai/guides/ankra-pipelines.md#newest-commit-wins). Set without a group it does nothing, and the validator warns. |
| `workspace.size` | quantity | none | The per-run volume every stage shares, for example `"10Gi"` |
| `workspace.access` | `rwo` or `rwx` | `rwo` | `rwo` is a volume one node can attach at a time. The agent pins each step, and its cache and artifact helpers, to the node the volume is attached to (or, before its first attach, the node the scheduler selected for it), so sequential steps do not wait for the volume to detach and re-attach elsewhere. The pin is dropped, and the step log says why, when that node is gone, cordoned or not Ready. `rwx` needs a StorageClass that serves it named on the claim; nothing names one today, so an `rwx` workspace is refused at dispatch rather than left waiting for a claim that never binds. |
| `defaults.image` | string | none | The image of every stage that names none |
| `defaults.timeout` | duration | 30 minutes | The timeout of every stage that names none, for example `"15m"` |
| `defaults.resources` | block | 500m CPU, 1Gi memory | `cpu`, `memory`, `gpu` for every stage that names none |
| `defaults.cache` | list | none | [Caches](#caches) every stage declares unless it declares its own |
| `defaults.working_directory` | string | the workspace root | Working directory of every stage that names none |
| `defaults.network` | tier | the kind's default | Network tier of every stage that names none, in place of that kind's own default - so `defaults.network: "none"` denies egress to a `build` or a `scan` stage that names no tier of its own, and does it quietly, because the validator's warning reads a stage's own declaration rather than this one. A value above `egress-https` is a protected section. |
| `defaults.env` | map | none | Environment variables for every stage |
| `defaults.secrets` | list | none | Declared secrets every stage may read |
| `permissions` | map | none | Scope to `none`, `read` or `write`. The authority granted is the intersection of this block, the organisation's policy and the target environment; it can only narrow. Protected. |
| `secrets` | list | none | `name`, `from` (`app_env_secret`, `org_variable`, `registry` or `credential`) and optional `key`. A stage may only name a secret this list declares. Protected. |
| `credentials` | list | none | `name`, `from` (`cloud`, `git`, `registry` or `object_storage`) and `as`, the name it is materialised under. Declared once for the whole run and materialised for every step of it - there is no per-stage `credentials` block, and unlike `secrets`, a stage does not narrow which of them it receives. A `credentials:` key written on a stage is an unknown key: it is ignored, `validate` warns, and nothing is delivered. Protected, and refused for a fork run. |
| `services` | map | none | Sidecars by name: `image` (required), `env`, `ports`, and `ready.tcp` (a port) or `ready.http` (a path, probed on the first declared port). Without a probe the stage starts as soon as the container does. A sidecar's image is held to the organisation's image policy and its `env` to the same reserved names a step's own is - either violation refuses the whole step rather than running it. Protected. |
| `stages` | list | required | The main DAG |
| `on_failure` | list | none | Stages that run after the main `stages` have all concluded, and only when one of them failed. See [Failure and cleanup stages](#failure-and-cleanup-stages) |
| `finally` | list | none | Stages that run after every main and `on_failure` stage has concluded, whatever the outcome, and after a run that had started is cancelled. See [Failure and cleanup stages](#failure-and-cleanup-stages) |
| `path_sets` | map | none | Named lists of path patterns. Any path filter list includes one with a `{set: <name>}` entry. See [Path sets](#path-sets) |
| `environments` | map | none | The pipeline's half of each deployment environment binding: `data` (`none`, `restore_point` or `seed`), `verify` (stage names), `rollout` (`recreate`, `rolling`, `blue_green` or `canary`) and `gate` (`automatic`, `manual` or `custom`). Protected. |

Stage names must be unique across `stages`, `on_failure` and `finally`, because the three share one run and one output namespace.

### Failure and cleanup stages

A run has three phases, in this order: the main `stages`, then `on_failure`, then `finally`. Every `on_failure` and `finally` stage waits on every stage of the phases before it without declaring it.

* **`on_failure` runs only when the main stages failed.** Once every main stage has concluded, the stage runs if one of them failed, timed out, or was stopped by an infrastructure fault or by its agent. A failure that `continue_on_error` or `allow_failure` keeps from failing the run does not count. After a green run the stage is skipped with the reason "Skipped because the run did not fail, so this on\_failure stage was not needed."
* **`finally` runs whatever the outcome.** It starts once every main and `on_failure` stage has concluded: after a green run, after a failed one, and after `on_failure` stages that themselves failed.
* **`needs:` inside a phase works as it does in `stages`.** An `on_failure` stage that `needs:` another `on_failure` stage runs only when that one succeeded, and the same holds between two `finally` stages. A `needs:` on a main stage only orders the stage and gives it that stage's `needs.<stage>.*` values; it never stops an `on_failure` or `finally` stage from running when the main stage failed.
* **They count toward the run's outcome.** An `on_failure` stage that succeeds does not turn a failed run green. A `finally` stage that fails fails a run whose main stages passed; give it `allow_failure: true` when a failed cleanup should not do that.
* **A failed-only re-run** repeats an `on_failure` stage together with the main stage it responds to, and runs it again only if that stage fails again. A `finally` stage that failed is repeated on its own.
* **`finally` also runs when a run is cancelled.** See [Cancelling a run](#cancelling-a-run) below.

#### Cancelling a run

A run can be cancelled as a whole in five ways: a person cancels it from the portal, the CLI, the API or the check run's **Cancel** button; a newer run takes its concurrency group (`cancel_in_progress: true`); its pull request is merged or closed; the merge train stops testing its car; or `cancel_queued` replaces it before it starts.

When the run had already started, it cancels the way GitHub Actions treats `if: always()` steps and GitLab treats `after_script`:

1. Every main and `on_failure` stage that has not finished stops at once and concludes `cancelled`. Their pods are stopped. `on_failure` stages do not start, because a cancel is not a failure.
2. The `finally` stages run once the stopped stages' pods have stopped (at most three minutes after the cancel, if a cluster does not answer). Each keeps its own `timeout`.
3. The run concludes `cancelled`, whatever the `finally` stages did. Who cancelled it and why are recorded when you cancel, not at the end.

While the `finally` stages run, the run reads `running` with `cancelling: true` in the API, `ankra pipeline get` and the check run, and the check run stays in progress. It never reports success. Its title says the run was cancelled and is running its finally stages. Cancelling it again answers that it is already cancelling.

Some limits apply:

* **A run that never started runs no `finally`.** A queued run, including one `cancel_queued` replaces, has done nothing to clean up, so it concludes `cancelled` at once.
* **Cleanup has a budget of 15 minutes from the cancel.** `finally` stages still running after that are stopped and the run concludes `cancelled`.
* **Force cancel skips cleanup.** `ankra pipeline cancel <run> --force`, or `POST .../pipeline-runs/{run_id}/cancel?force=true`, concludes the run at once. On a run that is already running its `finally` stages, it stops them.
* **The concurrency group.** With `cancel_in_progress: false`, a cancelled run keeps its group while its `finally` stages run, so two runs of the group never overlap, cleanup included. The next run waits at most for the cleanup budget. With `cancel_in_progress: true`, a newer run takes the group at once and the cancelled run's `finally` stages finish outside it. Scope that cleanup to its own run, for example by naming what it creates and deletes after the `ANKRA_RUN_ID` variable every step has.

Stopping a single stage where it ran is not a cancel of the run. An infrastructure fault or an agent stopping a stage counts as a failure: `on_failure` runs, then `finally`.

## Triggers

| Key | Fields | Fires on |
| - | - | - |
| `on.push` | `branches`, `paths` | A push to a matching branch whose changed files match `paths`. Empty `branches` matches every branch; empty `paths` matches every change. |
| `on.pull_request` | `branches`, `paths`, `fork_policy` | A pull request opened, synchronised or reopened against a matching **base** branch, or retargeted to one: a change of base branch plans a new run for the same head against the new base, and its check replaces the old one. `fork_policy` is `none` (refuse), `read_only` (the default: no secrets, no credentials, no identity beyond reading the run) or `trusted`; it is a protected section. A run that ends up restricted is also budget-clamped - at most 8 matrix legs per stage, a 60-minute step timeout, 2 CPU and 4Gi per step, no sidecar services, no shared caches and 256 MiB of artifacts and logs per run - and the clamps are applied to the plan, so what a reviewer reads is what executes. See [Forks](https://docs.ankra.ai/guides/ankra-pipelines.md#what-a-step-runs-as). |
| `on.tag` | `patterns` | A pushed tag matching one of the glob patterns |
| `on.schedule` | list of `cron`, `branch`, `stages` | A five-field cron expression, numeric fields only (`MON` and `JAN` are refused, because the scheduler evaluates the fields numerically). `branch` defaults to the repository's default branch; `stages` restricts the run to a subset, which is how a nightly runs only the expensive suites. Schedules fire from a stored watermark, through the same trigger every push and pull request uses. |
| `on.manual` | `inputs` | A dispatch from the CLI or the API with the declared inputs |
| `on.webhook` | none | Declares an inbound dispatch against a per-pipeline token. Presence is the declaration; the tokens are not available yet. |

**Which schedule entry a firing uses.** A schedule you create with `ankra pipeline schedules create` (or the API) stores its own `cron` and `ref`; it does not point at an entry of `on.schedule`. When it fires, the run is planned from the first `on.schedule` entry, in the order the file declares them, whose `cron` has the same five fields as the schedule's (spacing does not matter) and whose `branch` is the schedule's ref, or is left out. That entry's `stages` decide the run, and they decide it everywhere the run is planned: when it is queued, when it is admitted and when each step is dispatched. A re-run of the run plans the same entry, so re-running a red nightly repeats the nightly, not the whole pipeline. A firing that matches no entry is refused with "No schedule this pipeline declares matches the cron this run was triggered for." and the schedule records that as its last outcome; it never falls back to running the whole pipeline. `stages` is not closed over `needs` for you: a stage whose dependency is left out of the subset is skipped as `dependency_skipped`, so name every stage the subset needs (`checkout` included). `ankra pipeline validate` warns about such a subset, and about an entry no firing can reach because an earlier one with the same cron already covers its branch.

A manual input has a `name` (letters, digits and underscores, so it can be addressed as `${{ inputs.<name> }}`), a `type` (`string`, the default, `boolean`, `number` or `choice`), an optional `default`, an `enum` (required for `choice`, and the default must be one of its values), `required` and `description`. A dispatch whose inputs do not match is refused with the planner's diagnostics and records nothing.

A path filter is decided on the event's changed files, and never excludes a run when the diff could not be read or was truncated - a file the planner could not see is not a file that did not change; the run records that it passed on incomplete information. A push's changed files are read from the provider the same way on GitHub, GitLab and Bitbucket Cloud: from the head of the branch's last successful push run (the newest one that concluded `success` within 30 days) to the pushed commit, or from the push's previous head when the branch has no such run, up to 300 paths. Ankra uses GitHub's compare API, GitLab's repository compare and Bitbucket Cloud's diffstat, and first checks with the provider that the starting commit is still in the pushed commit's history. So a push that follows a cancelled or failed one still sees the files those pushes changed, and a force push is caught on every provider, GitLab included. When there is no range to read, because the push created the branch or because history was rewritten past that commit, no path filter excludes anything. A pull request's files are read from the provider when the run is triggered (GitHub's pull request files, a GitLab merge request's diffs, Bitbucket's diffstat), up to 300 paths. The list is stored on the run, and a re-run reuses it. When a filter had to pass without a complete list, the run's `paths_unverified` says why: `provider_error` (the provider was asked and failed), `truncated` (more than 300 paths changed), `unsupported_provider` (the provider or the event offers no list), `branch_created` (the push created its branch, so there is no earlier commit to compare with) or `forced_push` (the push rewrote the branch's history, so its commits do not name every file that differs).

Every path list here, and every path list on a stage, can include a named list from `path_sets:` with a `{set: <name>}` entry. See [Path sets](#path-sets).

The `on:` triggers decide whether a run starts at all, for the whole pipeline. To run one stage on a narrower set of changes for one event only - a component's checks on a pull request that touches it, but on every push - give the stage an [event-scoped `when`](#event-scoped-filters) block rather than a second trigger or a duplicate stage.

### Setting path filters aside

A path filter lets a run skip what a change did not touch. When you want to see every stage anyway, or want some stages never to be skipped, set the filters aside. Nothing here changes a stage's `if:`, which still decides.

A **full run** runs every stage whatever the path filters say: `on.<event>.paths`, `when.paths` (and an [event block's](#event-scoped-filters) `paths`) and an [affected-only](#fields-common-to-every-stage) stage's `paths:` all pass. A run is a full run when one of these asks for it:

| Who asks | How | Recorded as |
| - | - | - |
| A person or token starting a run | `ankra pipeline run --full`, or `"full_run": true` in the body of `POST …/pipeline-runs` | `dispatch`, with who asked |
| A person re-running a run | `ankra pipeline rerun <run> --full`, or `?full_run=true` on `POST …/pipeline-runs/<run>/rerun` | `rerun`, with who asked. A re-run of a full run is a full run again without the flag |
| The `ankra:full-run` label on a GitHub pull request | Add the label. A full run of the pull request's head starts at once, every later push to the pull request runs in full while the label stays on it, and so does its [merge train](https://docs.ankra.ai/guides/ankra-pipelines.md#the-merge-train-test-every-pull-request-as-it-will-merge) car. Remove the label and the next run applies the filters again | `label`, with who added it |
| Your organisation | `ci_full_run_on_default_branch` in the [organisation CI settings](https://docs.ankra.ai/guides/ankra-pipelines.md#organisation-ci-settings) runs every push to a repository's default branch in full | `organisation_policy` |
| The pipeline | A top-level `full_run` block naming the `events` and `branches` it covers. An empty `events` list covers every event and an empty `branches` list every branch; for a pull request the branch is the one it targets | `pipeline` |

```yaml theme={null}
# Pull requests skip what they do not touch. Every push to main runs everything.
full_run:
  events: [push]
  branches: [main]
```

The run records why it set its filters aside: `full_run_source`, `full_run_requested_by` and `full_run_message`, the sentence the run page and `ankra pipeline get` show, for example "Path filters were set aside: the pull request carries the ankra:full-run label." The plan carries a `plan_paths_set_aside:` diagnostic. A request for a full run never joins a run of the same commit that applied its filters; it queues a run of its own.

**Always-run stages.** `ci_always_run_stages` in the [organisation CI settings](https://docs.ankra.ai/guides/ankra-pipelines.md#organisation-ci-settings) names stages that run whatever their path filters say, together with every stage they need. It applies to every pipeline in the organisation that declares a stage of that name, and a name a pipeline does not declare changes nothing there. When the trigger's own `on.push.paths` or `on.pull_request.paths` matches none of the changed files, the run still starts for the always-run stages and what they need, and every other stage is skipped as `path_filter`. A stage that ran only because of the setting carries a `plan_paths_set_aside:` diagnostic. An always-run stage does not make the run a full run.

`paths`, `when` and `if` are logic, not [protected sections](https://docs.ankra.ai/guides/ankra-pipelines.md#logic-is-open-authority-is-closed): a pull request can change them, its own filters included. That is deliberate, because an author can rewrite a stage's script anyway. A stage a pull request must not be able to filter away belongs in `ci_always_run_stages`, which lives in the organisation's settings, outside every repository, and only an organisation administrator can change. `full_run` is logic too, and only ever makes a run do more.

### Skipping a push with \[skip ci]

A pushed commit whose message carries `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip ankra]` or `[ankra skip]` (in any case), or whose last paragraph has a `skip-checks: true` trailer, records a skipped run instead of queueing one: outcome `skipped`, error class `skip_marker`, and a message naming the marker. The skipped run still posts its check. `[skip ankra]` and `[ankra skip]` skip Ankra Pipelines alone; GitHub Actions' own `[skip actions]` does not skip Ankra.

Only the head commit of a branch push counts. A tag push, a pull request run and a merge train car never honour a marker: a tag releases, and a pull request's check is what branch protection reads, so a line in a commit message must not turn it green. Where a branch push may be skipped is `ci_skip_markers` in the organisation CI settings:

| `ci_skip_markers` | A marker skips a push to |
| - | - |
| `except_default_branch` (the default) | Any branch but the repository's default branch |
| `everywhere` | Any branch, the default branch included |
| `never` | Nothing. Markers are ignored |

The default keeps every push to the default branch running, because that run is what releases and what catches whatever a pull request's path filters left out.

## Stages

Every stage has a `name` (lower-case letters, digits, dashes and underscores, starting with a letter or digit - it becomes an expression key, a Job name component and a check-run title) and a `kind`. The kinds are a closed vocabulary; an unknown kind is fatal rather than a stage the planner skips.

| Kind | What it does | Today |
| - | - | - |
| `checkout` | Materialises the repository at the run's ref into the workspace, reading `with.fetch_depth` (default `1`, `0` for full history), `with.submodules` (`false`, `true` or `recursive`) and `with.lfs` | Executes end to end: a pinned public git image, a shallow fetch of the exact commit with a fallback to its ref, `lfs: true` refused by name rather than silently checking out pointer files. A pull request from a fork clones the fork's own head anonymously, reading the head repository the trigger recorded on the run; refused, rather than guessed, only on the rare delivery whose fork head repository could not be established at all. |
| `run` | Executes `run` (a script, under `/bin/sh -eu`) in an image the organisation's policy allows; `uses` is refused at validation until the step library ships (below) | Executes end to end |
| `build` | Builds a container image by digest in a rootless BuildKit pod in `ankra-ci-build` | Executes end to end: resolves its own builder, source and push target at dispatch (a bare repository, or a component with no resolvable registry, gets a named refusal instead of a guess) and pushes by digest to the organisation's CI staging repository; a fork run's build has no push target, so it builds and discards. When the CI cluster cannot build - its agent runs no pipeline steps, or its node runtime confines the rootless builder ([`build_runtime_confined`](https://docs.ankra.ai/guides/ankra-pipelines#when-something-fails)) - and the organisation set [`ci_build_fallback: platform_builders`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings) and holds the platform builds grant (`platform_builds_enabled`; Ankra makes it per organisation on request), the step runs on [Ankra platform builders](https://docs.ankra.ai/guides/ankra-pipelines.md#ankra-platform-builders) instead, with the same downstream `scan`/`gate`/`publish`. Without the grant the step is refused as `build_fallback_none`, whatever the setting says. The build pod names no seccomp or AppArmor profile and no setting renders it `Unconfined`; on a confining node the fallback is the supported route, not a workaround. |
| `scan` | Runs the scanners over the source and the built image | Executes end to end: one step per declared scanner - Trivy, Semgrep, Checkov - each a pinned public image, writing the reports `gate` and the application's Security tab both read. The filesystem legs honour the stage's `working_directory`, so a monorepo component scans its own tree |
| `gate` | Evaluates persisted findings against organisation policy | Executes end to end: judges the run's own persisted findings against [`ci_image_gate`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings), which the stage's own `with:` may only tighten and each required `scan` stage's own `fail_on` tightens further per scanner (below); a declared scanner that recorded nothing fails the gate by name. It records the digest it judged on its own step outputs |
| `publish` | Re-tags an approved image digest in the registry | Executes end to end: re-tags the exact digest `gate` approved as `sha-<7>` in the application's real repository, then gives the same digest every tag under `publish.tags` (below); refuses by name when its run has no successful `gate` step, when the build's digest is not the one the gate recorded judging (or the gate recorded none), or - in a monorepo - when its own stage name matches neither the bare nor the component-suffixed convention, so it cannot tell which component's build it is publishing |
| `preview` | Deploys the run's artefact into a per-pull-request environment, from inside the pipeline DAG | No executor yet: warned at validation, skipped by the planner - distinct from an Application's own pull-request preview, which already deploys an `ankra_pipeline` application's `build` digest without this stage kind existing (see [Ankra Pipelines](https://docs.ankra.ai/guides/ankra-pipelines.md#how-a-run-happens)) |
| `verify` | Runs one verification: `probe`, `smoke`, `load_test`, `assert_promql` or `restore_verify` | No runner image on this build, so the step could only fail to start: warned at validation, skipped by the planner rather than dispatched |
| `deploy` | Rolls a release out to the host targets of the stage's `environment`: machines outside Kubernetes that run `ankra-host-agent` (see [`ankra targets`](https://docs.ankra.ai/reference/cli/targets)). `deploy.artifact` names the `publish` stage whose digest is deployed, which must be inside this stage's `needs`; `deploy.release` is the release name; `deploy.targets.match_labels` narrows the targets | Executes, settled by the platform: every active host target of the environment that matches the labels pulls the release, and the step concludes from what the hosts report, with the outputs `deployment_id`, `digest`, `environment`, `targets_succeeded` and `targets_failed` (see [`ankra deployments`](https://docs.ankra.ai/reference/cli/deployments)). The artifact must be a signed release bundle (a `build` stage with `bundle`) that this run published. Concluded skipped when the stage has no `deploy` block or names an environment the organisation does not have; failed when the run's protected sections are not approved, the artifact is not such a bundle, or no target matches. A pull request run or a fork run never deploys: the planner skips the stage as `deploy_untrusted_event`. It does not deploy to a Kubernetes cluster; an application's push to deploy does that |
| `agent` | An AI mission with a `goal`, `tool_profile`, `mode` (`ask` or `agent`), `budget` (`tool_calls`, `turns`, `minutes`) and `context` | No executor yet: warned at validation, skipped by the planner |
| `approval` | Blocks the run on a human decision: `roles` (required), `min_approvals`, `timeout`, `message`. Never satisfiable by an AI or a service token | No executor yet: warned at validation, skipped by the planner - so it blocks nothing today, and everything downstream of it runs |
| `webhook` | Posts a signed payload to `url` with `method`, optionally `wait_for_callback` within `timeout` | No executor yet: warned at validation, skipped by the planner |
| `automation` | Runs an AI automation graph and reads its outputs | No executor yet: warned at validation, skipped by the planner |
| `ankra` | Runs a platform verb through the run's scoped identity | No executor yet: warned at validation, skipped by the planner |
| `external` | Waits on a provider's own CI: `provider`, `workflow`, `inputs` | No executor yet: warned at validation, skipped by the planner |

A stage whose kind has no executor on this build is reported twice, in the same words. The validator raises a **warning** under `pipeline_executor:` naming the stage, its kind and the wave that ships the executor - a warning rather than a fatal, deliberately, because refusing the document would stop every other stage in it from running too. The planner then skips exactly that stage with reason `kind_unavailable`, checked before every event-dependent filter because it is a static fact about the stage. The run therefore concludes on its own; before this it sat `pending` forever and `ankra pipeline cancel` was the only way to release its concurrency group.

### Event-scoped filters

A stage's `when` can name the event a filter belongs to. For the run's event, the stage's block for that event - `push`, `pull_request`, `tag`, `schedule` or `manual` - supplies the branches and paths, and the flat `when.branches` and `when.paths` do not apply to it at all; an empty block means that event is unfiltered. Events with no block keep the flat fields. `when.events` is unchanged and still has to include the event. A re-run is planned as the event the run it repeats was planned as, so its event blocks and `when.events` decide the same way they did the first time. A re-run of a pull request run reads the `pull_request` block, and so does a re-run of a [merge train](https://docs.ankra.ai/guides/ankra-pipelines.md#the-merge-train-test-every-pull-request-as-it-will-merge) run, which is planned as a pull request. A re-run of a push reads the `push` block and plans the push-only stages that push planned, `publish` included. A re-run of a schedule run plans that schedule firing again, with the same `stages` subset. A re-run also reuses the original run's stored change list, so a stage filtered only by `when.pull_request.paths` is left out of a re-run of that pull request exactly when it was left out the first time. A re-run of any other run (a tag, a manual or API dispatch, an agent run) is planned as its own `rerun` event, and an agent run as an `agent` event. Neither has a block, so both read the flat fields against the stored change list, and a stage runs in such a re-run only when its `when.events` lists `rerun` or is empty.

```yaml theme={null}
stages:
  - name: frontend
    kind: run
    image: node:22
    run: pnpm test
    when:
      pull_request:
        paths: ["frontend/**", ".ankra/**"]   # a pull request runs it only when frontend changes
      push: {}                                 # every push runs it
  - name: backend
    kind: run
    image: golang:1.26
    run: go test ./...
    when:
      paths: ["backend/**"]                    # every event, unless it has a block
      pull_request:
        paths: ["backend/**", ".ankra/**"]
```

A block for an event the pipeline's `on:` does not trigger on, or that the stage's own `when.events` leaves out, can never apply and is a warning under `pipeline_trigger:`. Converted pipelines use these blocks: a GitHub Actions repository's workflows keep their own `on.pull_request.paths` and `on.push.paths` on their stages, and a GitLab rule whose `if:` tests `$CI_PIPELINE_SOURCE == "merge_request_event"` or `== "push"` becomes `when.pull_request` or `when.push`. The recipe for a monorepo is [PR-only checks, full builds on push](https://docs.ankra.ai/guides/ankra-pipelines.md#monorepo-pr-only-checks-full-builds-on-push).

### Path sets

A monorepo filters many stages on the same few directories. `path_sets:` names a list of patterns once, and every path list includes it with a `{set: <name>}` entry: `on.push.paths`, `on.pull_request.paths`, a stage's `paths`, `when.paths`, `when.push.paths` and `when.pull_request.paths`. A set can include other sets the same way.

```yaml theme={null}
apiVersion: ankra.io/v1
kind: Pipeline
metadata:
  name: shop
path_sets:
  pipeline: [".ankra/**"]
  backend:
    - "backend/**"
    - "!backend/**/*.md"
    - set: pipeline
  frontend: ["frontend/**", "packages/ui/**", {set: pipeline}]
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
defaults:
  network: egress-https
stages:
  - name: checkout
    kind: checkout
  - name: test-backend
    kind: run
    image: golang:1.26
    run: go test ./...
    needs: [checkout]
    paths:
      - set: backend
      - "go.work"
  - name: test-frontend
    kind: run
    image: node:24
    run: pnpm test
    needs: [checkout]
    when:
      pull_request:
        paths: [{set: frontend}]
      push: {}
```

* A set name uses lower-case letters, digits, `-` and `_`, and starts with a letter or digit, like a stage name.
* An entry is a pattern, with the same glob rules as any path list (`*`, `**`, `?`, a leading `!` to exclude), or `{set: <name>}`. A path list can also be a single `{set: <name>}` in place of a list.
* A reference is replaced by the set's patterns, in order, where it stands. The list then means exactly what it would mean with the patterns written there, so the last pattern that matches a file still decides, and a `!` pattern in a set excludes from the list it lands in.
* Ankra expands the sets when it reads the file. Plans, the stored definition and its hash are those of the expanded lists, so a file written with sets and the same file written out by hand are the same pipeline. Paths are not a protected section, so changing a set never needs an administrator's approval.
* `{set: <name>}` is accepted only in the path lists above. In `needs`, `branches`, a cache's or an artifact's `paths` or any other list it is refused.

Refused under `pipeline_path_sets:`: a reference to a set `path_sets:` does not declare (the message suggests the closest name), sets that include each other in a cycle, an invalid set name, a set declared twice, an entry that is neither a pattern nor `{set: <name>}`, and an empty pattern. A set nothing references, directly or through another set, and an empty set are warnings. A set entry spelled so that it can match no file is a `pipeline_paths:` warning that names the set and the entry, once, not again for every list the set is used in.

`ankra pipeline validate` lists every set with its expanded patterns, the sets it includes and the lists that use it. The API answer carries the same as `path_sets`.

### Fields common to every stage

| Field | Type | Default | Meaning |
| - | - | - | - |
| `image` | string | `defaults.image` | The container the stage runs in. Required for `run`, the one kind that runs in the image its definition names: a `run` stage that resolves no image, its own or `defaults.image`, is a fatal violation with the key `pipeline_image:`, so the definition is refused at validation and no run is planned from it, naming the stage. Ignored for `build` (the builder is Ankra's), `checkout` (the git client is Ankra's own, pinned by digest) and `scan` (the scanners' images are Ankra's) - any of them may declare one for readability, but the platform's own image runs regardless. A `verify` stage has no runner on this build: it is warned at validation and skipped by the planner whatever its `image` says (see the `verify` row under [Stages](#stages)), and `pipeline_image:` does not apply to it. What a stage may name is held to the organisation's [image policy](https://docs.ankra.ai/guides/ankra-pipelines.md#organisation-ci-settings), matched at a path boundary: a policy prefix ending in `/`, `:` or `@` admits only that boundary. |
| `uses` | string | none | A reusable step, spelled `ankra/<name>`, `custom_tool/<name>` or `repo:<owner>/<name>@<ref>#<path>`. **Refused today**: any `uses:`, however it is spelled, is fatal under `pipeline_uses_unavailable:` with the sentence "Reusable steps are not available yet; write the step as `run:`", until the step library ships. A stage declares `run` or `uses`, not both. |
| `with` | map | none | Inputs to `uses`, and where a `gate` stage's own image-gate configuration lives. Protected. |
| `run` | string | none | The script; required on a `run` stage without `uses` |
| `needs` | list | none | Stages this one waits for. Every name must exist and the graph must be acyclic. |
| `skippable_needs` | list | none | The entries of `needs` this stage may run without. When a listed stage is left out because its own path filter (`when.paths`, or the [event block's](#event-scoped-filters) `paths` for this run's event) matches no changed file, this stage waits on what that stage would have waited on instead of being skipped with it. When the listed stage runs, it is an ordinary need: this stage runs after it, and only if it succeeded. Any other reason the listed stage is skipped - a branch or event filter, a false `if`, the fork policy - still skips this stage, unless this stage's own `if:` calls a status function (see `if`). Every entry must also be in `needs`; an entry naming a stage with no path filter has no effect and is a warning. |
| `paths` | list | none | Makes the stage **affected-only**: it runs only when a changed file matches one of these globs (the `when.paths` dialect: `*`, `**`, `?`, a leading `!` to exclude), or a [path set](#path-sets) the list includes. When none does, the stage is skipped as `path_filter` and **counts as satisfied** for every stage that needs it: each of them waits on what the skipped stage waited on instead of being skipped with it, so a monorepo can leave out the tests of the components a change did not touch and still build, gate and publish. That is the difference from `when.paths`, which skips the stages behind it unless each lists it in `skippable_needs`. A change list that could not be read in full never leaves the stage out. Only `paths` may be the reason it is out: when its `if` is false for the run, or can only be decided at dispatch, the stages behind it are skipped as for any other need. Refused (`pipeline_paths:`): a stage that reads its outputs, a `build` another stage needs, a `checkout`, `gate` or `approval`, a scan a gate names in `require_stages`, and `paths` together with a `when` path filter on one stage. See [Affected-only stages](https://docs.ankra.ai/guides/ankra-pipelines.md#affected-only-stages-skip-what-a-change-did-not-touch). |
| `affected` | map | none | Makes the stage **affected-only** from the code's dependency graph instead of, or as well as, globs. `go` lists Go package patterns (`./...`, `./internal/api` relative to the stage's `working_directory`, or import paths; only a trailing `/...` is a wildcard), `pnpm` lists pnpm workspace packages by name (`@acme/web`) or by `./directory`. The stage runs when a changed file is in the dependency closure of what it names, tests included, or matches its `paths` (an OR: list the files the code reads at run time there). Otherwise it is skipped as not affected (class `not_affected`) and counts as satisfied for every stage that needs it, as for `paths`. The run's checkout step reads the graph of the commit it checked out and answers per stage; a stage with no answer runs. Every `paths` refusal applies, and patterns may not carry `${{ }}`. See [Affected from the dependency graph](https://docs.ankra.ai/guides/ankra-pipelines.md#affected-from-the-dependency-graph-affected). |
| `if` | expression | none | Runs the stage only when the expression is true; see [Expressions](#expressions). As in GitHub Actions, an `if:` that calls a status function - `always()`, `success()`, `failure()`, `cancelled()`, `skipped()` - replaces the implicit "every need succeeded": such a stage is not skipped because a need was left out of the run. It waits on the nearest stages behind that need that do run, and its condition decides at dispatch, where `needs.<stage>.result` reads `skipped` and `skipped()` is true. |
| `when.branches`, `when.paths`, `when.events` | lists | none | Filters on the event without an expression, decided before any expression context exists. `branches` and `paths` apply to every event that has no [event block](#event-scoped-filters) of its own; `events` always applies |
| `when.push`, `when.pull_request` | `branches`, `paths` | absent | The branch and path filter for that one event, in place of the flat fields; an empty block (`{}`) means the event is unfiltered. See [Event-scoped filters](#event-scoped-filters) |
| `when.tag` | `patterns` | absent | The tag names this stage runs for on a tag push |
| `when.schedule`, `when.manual` | none | absent | Accepted only empty: the event is unfiltered. `when.manual` covers an API dispatch too |
| `matrix` | map | none | Axes (`name: [values]`) plus `include` and `exclude` lists, with GitHub Actions semantics; at most 64 legs after exclusions, and 8 on a restricted fork run, where the surviving legs are truncated in cross-product order with a `plan_fork_budget:` diagnostic. An axis may not be named `include` or `exclude`. |
| `services` | list | none | Names from the `services` block this stage reaches |
| `env` | map | none | Environment variables; names beginning `ANKRA_`, `PATH` and the loader variables, and the builder's own `BUILDKIT*`, `BUILDCTL`, `ROOTLESSKIT` and `DOCKER_CONFIG` are refused |
| `secrets` | list | none | Names from the `secrets` block this stage may read |
| `cache` | list | `defaults.cache` | `key` (expressions allowed, typically `hashFiles`), `paths` under the workspace, optional `restore_keys` (an ordered list of key prefixes), `size` and `fallback` (`volume` or `none`). Each path is restored before the step and saved after it succeeds, scoped to the repository and to the trust of the run. See [Caches](#caches) for how keys are computed, what restores, what saves and how long it is kept. A restricted fork run and a tag run get no shared cache. `size` is held to the organisation policy's `max_cache_size`. The whole block is protected - a cache decides whose earlier output lands in this run's workspace. |
| `artifacts` | list | none | `name`, `paths` and optional `retention_days` (zero takes the organisation default). Every artifact is a presigned upload into the organisation's oldest ready [backup vault](https://docs.ankra.ai/concepts/backup-vaults.md) - or, for an organisation that has none, into the Ankra-managed default vault, under a key prefix of its own. The step's execution says which: `pipeline_artifacts: "granted"` or `"granted (Ankra-managed default vault)"`. Only with no ready vault on either side is the step dispatched with no upload grants, recording `pipeline_artifacts: "unavailable: …"`. [Bringing your own vault](https://docs.ankra.ai/guides/ankra-pipelines.md#before-your-first-run) decides where the objects live, not whether they are collected. |
| `test_results` | list | none | `format` (`junit`, `go-test`, `pytest` or `playwright`) and `path`, a file or a directory of reports under the workspace. Ankra uploads each report after the step and records every test's outcome, duration and attempts; see [Test results](https://docs.ankra.ai/guides/ankra-pipelines.md#test-results). A path with a wildcard is a warning and is not uploaded. Never changes the step's own outcome. |
| `outputs` | map | none | The names the stage writes to `$ANKRA_OUTPUT`, read back onto the step |
| `timeout` | duration | `defaults.timeout`, else 30 minutes | Wall-clock budget; at most 6 hours, longer values are clamped and recorded - 60 minutes on a restricted fork run. Protected, so a head cannot remove its own or the trusted definition's |
| `resources.cpu`, `resources.memory`, `resources.gpu` | quantities | 500m, 1Gi, none | The step's ceilings. `cpu` is the most the step may use: it reserves half of it on its node (never under 100m, or the whole of a smaller ceiling) and uses the rest while the node has it free. `memory` is reserved in full. At most 8 cores, 32 GiB and 4 GPUs - 2 cores and 4Gi on a restricted fork run, where a quantity the planner cannot read is clamped to the ceiling rather than passed through. All three are protected, and `gpu` also needs `runs_on.node_selector`. |
| `runs_on.cluster`, `node_selector`, `tolerations`, `runtime_class`, `arch` | placement | none | Every field is protected |
| `network` | `none`, `egress-https`, `services` | `defaults.network`, else [the kind's default](#the-tier-a-stage-gets-by-default) | `none` denies all egress. `egress-https` allows 53/UDP and 53/TCP to the cluster's own DNS (the `kube-system` namespace, plus `169.254.0.0/16` for NodeLocal DNSCache) and 443/TCP to public addresses only - RFC1918, `169.254.0.0/16`, `100.64.0.0/10`, `fd00::/8`, `fe80::/10` and `fd00:ec2::254` are excluded, so private space and every cloud metadata endpoint are out of reach unless the organisation names the range in [`ci_egress_allowed_cidrs`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings). `services` is deny-all plus the run's own pods and that same DNS rule, and needs at least one service. Anything above `egress-https` is protected. A stage that names no tier takes its kind's default, which is `egress-https` rather than `none` for `checkout`, `build` and `scan`. |
| `shm_size` | quantity | `64Mi` | Size of `/dev/shm` |
| `working_directory` | string | `defaults.working_directory` | Under the workspace mount. A `scan` stage's filesystem scanners honour it too, so a monorepo component scans its own subtree; the reports still land at the workspace root |
| `allow_failure` | flag | `false` | The stage may fail without failing the run |
| `continue_on_error` | flag | `false` | Stages depending on this one still run when it fails. Setting both flags earns a warning: `allow_failure` alone is what keeps the run green. |
| `retry` | block | none | `max_attempts` (counts the first attempt; at most 5), `backoff` (`fixed` or `exponential`), `on` (`failure`, `timeout`, `infra_error`). Validated and carried today, not applied. |
| `gate.approval_roles`, `gate.require_stages` | lists | none | The `gate` block; protected |
| `environment` | string | none | An environment this stage targets; a name the pipeline does not declare earns a warning |

#### The tier a stage gets by default

A stage's egress tier is decided in one place, in one order: the tier the stage names, then `defaults.network`, then the default for the stage's kind.

| Kind | Tier when nothing names one |
| - | - |
| `checkout`, `build`, `scan` | `egress-https` |
| `run` and every other kind | `none` |

Those three cannot do their only job inside the cluster. `checkout` clones the run's source from a remote. `build` resolves the base images its Dockerfile names and pushes what it produces, both against a registry - a build with no egress fails on its first `FROM`. `scan` fetches a vulnerability database, a policy set and a rule registry, one per built-in scanner, and pulls the image its Trivy leg reads. Every other kind, `run` above all, works out of the shared workspace and asks nothing further of the network, so it keeps the deny-all default.

An explicit `network: "none"` on one of the three is still honoured - a pipeline that asks for no egress gets no egress - and the validator raises a `pipeline_network:` **warning** naming what that stage will no longer be able to do, rather than widening the tier back.

### Caches

A stage's `cache` block keeps directories of the workspace from one run to the next: a package store, a module cache, a build cache. Each declared path is kept on its own, under the cache's key.

```yaml theme={null}
cache:
  - key: "pnpm-${{ hashFiles('pnpm-lock.yaml') }}"
    paths: [".ankra-node-cache/pnpm-store"]
    restore_keys: ["pnpm-"]
    fallback: "none"
```

**The key is computed after checkout.** A `${{ hashFiles('...') }}` block becomes the SHA-256 of the files it matches, read in the stage's `working_directory`, so a key over a lock file changes exactly when the lock file does. A pattern is relative and `/`-separated: every directory is a literal name, at most one `**` may stand immediately before the file name, and the file name may use `*`. `hashFiles('pnpm-lock.yaml')`, `hashFiles('go.mod', 'go.sum')` and `hashFiles('**/go.sum')` all work; `hashFiles('**/src/go.sum')`, with a directory after the `**`, and `hashFiles('../go.sum')` do not. One call takes up to eight patterns, and files under `.git` are never hashed. A key the platform cannot read this way is used as written, as one fixed key, and the step's `pipeline_caches` note says so. A `hashFiles` that matches no file adds nothing to the key.

**Restore, before the step.** The exact key wins. Without it, each `restore_keys` prefix is tried in the order written, and the newest stored entry whose key starts with it is restored. A run on the repository's default branch restores only what default-branch runs saved. Any other run - a feature branch, a pull request, a manual run of another ref - looks in its own scope first and then at the default branch's, so a pull request starts warm from main, while nothing a branch saved ever reaches a default-branch run. A restricted fork run and a tag run, or a rerun of one, get no shared cache at all: every declared path starts empty, and the step's `pipeline_caches` note says `caches are not shared with fork runs` or `caches are not shared with tag runs`.

**Save, after the step succeeds.** A step that failed, was cancelled or timed out saves nothing. A key with a `hashFiles` block is saved once: when the exact key was restored, the content is already stored. A key with no `hashFiles` is saved again after every successful step, and the newest save of it wins. A run saves only into its own scope.

**How long it is kept.** An entry nothing restored for `ci_cache_retention_days` (default 14) is removed. Each repository keeps at most 10 GiB of saved caches across both scopes, and the least recently used go first past that; one saved path may be up to 4 GiB, and a larger one is not kept. There is no purge: change the key (`pnpm-v2-...`) to start over, and the old entries age out.

**`fallback`, on an older agent.** Caches are archives on a cluster whose Ankra agent supports them. On an agent that does not, a cache with `fallback: volume` - the default - is one 5Gi volume per path, shared by every run of the same trust scope on that cluster, with the key used as written rather than hashed; a cache with `fallback: none` gets an empty directory for the step instead, and the step's `pipeline_caches` note says so. A shared volume can be attached to one node at a time, so `none` is the safer choice for a cache that is only an optimisation: two overlapping runs on two nodes do not wait on each other's volume. `size` sizes that volume and is not used by archives.

**What Ankra generates.** The `.ankra/pipeline.yaml` a new application's setup pull request commits declares its test stage's package caches this way, with `fallback: "none"`, in directories under `.ankra-` that the scan step skips, and points the toolchain at them through the stage's `env`. A pipeline you already committed is never rewritten; copy the block into it to get the same.

| Toolchain | Key | Paths |
| - | - | - |
| pnpm | `pnpm-${{ hashFiles('pnpm-lock.yaml') }}` | `.ankra-node-cache/pnpm-store`, `.ankra-node-cache/corepack` |
| Yarn | `yarn-${{ hashFiles('yarn.lock') }}` | `.ankra-node-cache/yarn`, `.ankra-node-cache/yarn-global`, `.ankra-node-cache/corepack` |
| npm | `npm-${{ hashFiles('package-lock.json') }}` | `.ankra-node-cache/npm` |
| Go | `go-${{ hashFiles('go.mod', 'go.sum') }}` | `.ankra-go/mod`, `.ankra-go/cache` |
| uv | `uv-${{ hashFiles('uv.lock') }}` | `.ankra-python-cache/uv` |
| Poetry | `poetry-${{ hashFiles('poetry.lock') }}` | `.ankra-python-cache/pip`, `.ankra-python-cache/poetry/cache`, `.ankra-python-cache/poetry/artifacts` |
| Pipenv | `pipenv-${{ hashFiles('Pipfile.lock') }}` | `.ankra-python-cache/pip`, `.ankra-python-cache/pipenv` |
| pip | `pip-${{ hashFiles('requirements.txt') }}` | `.ankra-python-cache/pip` |
| Rust | `cargo-registry-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}` and `cargo-target-` the same | `.ankra-cargo/home/registry`, `.ankra-cargo/home/git`; `.ankra-cargo/target` |

Each key's `restore_keys` is its own prefix (`pnpm-`, `go-`, ...). In a monorepo each component's directories carry the component's directory (`.ankra-node-cache-web/...` for `web/`), because its test stage runs beside its siblings'. A Rust component gets a test stage when it commits integration tests under `tests/` and pins no toolchain file.

### What a step reports about its caches

On a cluster whose agent stores caches as archives in the organisation's vault, each step records one result for all its caches and one entry per cache. `ankra pipeline get` shows the result in the **CACHE** column, and `ankra pipeline get <run> --step <key>` prints the per-cache table. The portal's run timeline shows the same as a chip on the step.

| `cache_result` | Meaning |
| - | - |
| `hit` | Every declared cache was restored, by its exact key or a `restore_keys` prefix |
| `partial` | Some caches were restored and others were not |
| `restore_failed` | No cache was restored and at least one restore failed, so the step ran cold because something broke |
| `miss` | Nothing was stored yet for any declared cache |
| `disabled` | No cache was shared with the step: the cluster has no StorageClass to back a cache volume, or every cache declares `fallback: none` on an agent that does not keep archives |
| `unknown` | Nothing was reported, or the report could not be read. Never read it as a miss |

Each cache has an outcome (`hit`, `restored`, `miss` or `failed`), the bytes downloaded, the time taken, whether the step saved a new archive, and a reason:

| Reason | Meaning |
| - | - |
| `none` | The outcome is the whole story |
| `key_unresolvable` | The key could not be computed: `hashFiles` matched no file, or the working directory could not be read |
| `download_failed` | The vault did not serve the archive |
| `unpack_failed` | The archive downloaded and could not be unpacked or moved into place |
| `unschedulable` | The pod doing the restore was never scheduled |
| `pod_failed` | That pod could not start, or ended without reporting the cache |
| `timeout` | The restore ran out of time |

A failed restore never fails the step: the step starts cold and says so in its log on a line starting `[ankra] cache`.

Reasons, sizes and times come from an agent that reports them. An older agent reports the outcome only, and the reason is empty.

**Where the restore runs.** A stage on the `egress-https` tier with no organisation egress allow-list, whose vault is on public addresses, restores inside the step's own pod before the step starts, so it is scheduled exactly once with the step. Every other stage restores in a separate pod beside the step's, placed on the same node, because a step's network policy covers its whole pod and a `none` or `services` stage must not gain a route to the vault.

### Kind-specific blocks

| Block | Fields |
| - | - |
| `build` | `dockerfile`, `context`, `platforms`, `build_args` (names only), `provenance`, `sbom`, `cache_to`. **`dockerfile` and `context` are both paths from the repository root**, resolved independently of one another - see below. `provenance`/`sbom` ask BuildKit to attest the digest it pushes to the CI staging repository - `publish`'s re-tag does not carry those attestations into the real repository yet (below). |
| `scan` | `scanners` (`semgrep`, `checkov`, `trivy`) and `fail_on`, a map from scanner to a severity. `fail_on` is **read by the gate**: each scan stage the `gate` stage requires has its `fail_on` folded into the gate's policy, per scanner and tighten-only - it can raise that scanner's floor above the organisation's [`ci_image_gate`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings) and a `gate` stage's own `with:`, and can never lower either, so a pull request head may add scrutiny to its own pipeline but not remove the organisation's. A value that is not that scanner's own severity vocabulary - Checkov's `none`, a Trivy image-gate word such as `app` - carries no floor rather than the loosest one. `with.semgrep_config` names the space-separated Semgrep registry packs to run (for example `"p/default p/docker p/golang"`); undeclared, Semgrep runs its own `--config=auto`. Each tool leg declares its own reports as artifacts, so they travel the vault like any other: a report that never uploaded, or that downloaded and would not parse, revises the step from `success` to `infra_error` / `report_unreadable` rather than letting an unread scan count as a clean one. Reports travel to the organisation's own ready vault, or to the [Ankra-managed default vault](https://docs.ankra.ai/guides/ankra-pipelines.md#before-your-first-run) when it has none, so an organisation that never registered a vault still scans and still gates; with no ready vault on either side the step is refused before it runs, as `artifact_store_unavailable` (below). |
| `gate` | No block of its own beyond `gate.approval_roles`/`gate.require_stages` (above) - a `kind: gate` stage's `with.image_gate` (`app`, `all` or `off`, case-insensitive, the same vocabulary as [`ci_image_gate`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings)) and `with.severity` (`critical`, `high`, `medium` or `low`) may only make the organisation's policy *stricter*, never looser; `with.ignore_unfixed` (excludes a Trivy finding with no published fix) is floored the same way by the organisation's own [`ci_ignore_unfixed`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings), which is `true` by default: an organisation that sets it `false` has every unfixed finding keep blocking, and a stage's `with:` may then only move toward `false` as well. A gate judges the findings the run *persisted*, and a required scanner that recorded none fails it by name rather than passing as clean - so a gate cannot pass at all in an organisation whose scan reports had nowhere to be stored. |
| `publish` | `tags`: extra tags the published digest is given in the application's repository beside `sha-<7>` - a moving `latest`, a version such as `"0.2.${{ ankra.run.number }}"`. Each is a manifest re-tag of the one digest, in place: no blob is pushed again, and a tag that already points at the digest is left alone. Expressions resolve at plan time; a literal must be a tag a registry accepts (up to 128 letters, digits, `_`, `.`, `-`, not starting with `.` or `-`), and an entry listed twice or empty is refused at validation. The stage's `when:` governs the extra tags with the stage: a publish skipped for the branch writes none. A registry that refuses one of them - a tag-immutability rule on the project, say - fails the step naming the tag, so a `latest` that did not move never reads as published. The step's outputs carry them as `image_tags`. `with.image` is accepted and validated but not read: the real target is the organisation's own registry, resolved for the run's application and component exactly as `build`'s push target was - never a value a stage can name. It refuses by name unless a `gate` upstream concluded successfully and recorded the same digest `build` pushed, so the whole scan-to-registry chain rests on findings the run managed to persist. `promote_from: pull_request` lets a push publish the digest a pull request run already built and tested for exactly the same tree, instead of building and testing it again; see [Promote a tested image instead of rebuilding](#promote-a-tested-image-instead-of-rebuilding). It is a protected section. |
| `deploy` | `artifact` (the `publish` stage whose digest is deployed, by name or as `${{ needs.<stage>.outputs.image_digest }}`; it must be inside the stage's `needs`), `release` (equal to `metadata.name` in the bundle's `ankra-release.yaml`), `targets.match_labels`. The environment is the stage's own `environment`. The artifact is a release bundle: a `build` stage with `bundle.path` (a workspace directory) and `bundle.manifest` (its `ankra-release.yaml`) packs one instead of building an image. Protected. |
| `preview` | `database.enabled`, `database.extensions`, `database.seed_from_restore_point`, `migrate`, `ingress_path` |
| `verify` | Exactly one of `probe` (`url`, `status`, `timeout`), `smoke` (`image`, `run`), `load_test` (`script`, `virtual_users`, `duration`, `thresholds`), `assert_promql` (`query`, `comparison`, `threshold`, `for`) or `restore_verify` (`restore_point`, `queries`) |
| `agent` | `goal` (required), `tool_profile`, `mode`, `budget.tool_calls`, `budget.turns`, `budget.minutes`, `context` |
| `approval` | `roles` (required), `min_approvals`, `timeout`, `message` |
| `webhook` | `url` (required), `method`, `wait_for_callback`, `timeout` |
| `external` | `provider` (required), `workflow`, `inputs` |

#### Promote a tested image instead of rebuilding

A merge to the default branch normally builds, scans, tests and gates the merged commit again before `publish` releases it, even when a pull request run already did all of that for the same files. `publish.promote_from: pull_request` skips the repeat:

```yaml theme={null}
  - name: "publish"
    kind: "publish"
    needs: ["gate", "e2e"]
    when:
      events: ["push"]
    publish:
      promote_from: "pull_request"
```

When a push arrives, Ankra reads the git tree of the pushed commit and looks for a pull request run of the same repository that recorded the same tree. The push is promoted only when all of these hold:

* A pull request run tested exactly that tree. A squash merge of a pull request that was up to date with its base has the tree the pull request tested; a merge of one that was behind does not, and builds in full - unless the [merge train](https://docs.ankra.ai/guides/ankra-pipelines.md#the-merge-train-test-every-pull-request-as-it-will-merge) merged it, whose car run tested exactly the merged tree.
* Every stage the push's `publish` needs passed in that run, on its newest attempt, and resolved to the same stage definition. A build argument or a value drawn from the event (`ankra.ref`, `ankra.sha`) makes the image differ, so the push builds.
* A stage the run skipped counts only when the push would have skipped it too. A stage with [`affected:`](https://docs.ankra.ai/guides/ankra-pipelines#affected-from-the-dependency-graph-affected) that the run skipped as not affected counts when every file the push changes is among the files that run's dependency graph was asked about: the tree is the same, so a change the graph already showed cannot reach the stage still cannot. A push that changes a file the run did not, such as one push that carries two merges, builds in full, and its own checkout reads the graph. A merge train car is asked about its pull request's files, which are the files its merge changes, so a train merge promotes past the skip. A stage a `paths:` filter left out needs no rule: the push applies the same filter to its own change, so either it leaves the stage out too, or its change reaches the stage and the push builds. Any other skip, such as a false `if:`, never counts.
* The run's head is in the repository itself. A fork's run is never promoted.
* Both runs executed under the same approved [authority](https://docs.ankra.ai/guides/ankra-pipelines.md#logic-is-open-authority-is-closed), and the run's `gate` judged exactly the digest its `build` reported.
* The run was queued within the last 72 hours.
* Every stage of the push is `publish` or a stage `publish` needs, and every `publish` stage declares `promote_from`.

A promoted push runs `publish` alone. It re-tags the pull request run's gate-approved digest as the push's own `sha-<7>` in the application's repository, and push-to-deploy rolls it out exactly as after a full run. Every stage it skipped shows as skipped on the run, naming the pull request run that passed it, or that found it not affected. Anything short of the list above builds and tests the push in full, as without the key, and the run records why.

Deploys still move forward in push order. If an older merge that built in full publishes after a newer promoted one, it gets its own `sha-<7>` but deploys nothing, and a moving tag such as `latest` stays on the newer release.

`promote_from` is a protected section: an administrator approves it with the rest of the default branch's definition, and a pull request cannot turn it on for itself. The promoted image is the one the pull request run built, so its build provenance names the pull request's head commit, which carries the same tree.

#### Where a build's paths are resolved from

`context` and `dockerfile` are **two independent paths from the repository root**. `dockerfile` is *not* relative to `context` - the rule `docker build -f` and GitHub's `docker/build-push-action` hold, and the opposite of Docker Compose's `dockerfile:`. So a component in a monorepo names both in full:

```yaml theme={null}
- name: "build-backend"
  kind: "build"
  build:
    context: "backend"
    dockerfile: "backend/Dockerfile"
```

Declaring `context: "backend"` with `dockerfile: "Dockerfile"` points the build at the repository *root's* `Dockerfile`. If there is none, the step fails with exit status `78` and the message `The Dockerfile Dockerfile is not in the repository at this commit` - which names the path it looked for, and is the quickest way to see that the path was read from the root.

Omitting `dockerfile` entirely reads `Dockerfile` at the repository root - except in a pipeline Ankra generated for an application, where the field is already filled in with the path the application's analysis resolved.

Either path may only address files inside the checkout: an absolute path, or a relative one that climbs out with `..`, is refused. That refusal is raised when the step is rendered for dispatch, not when the definition is stored - so the definition saves cleanly, and the run instead concludes with the step refused for asking for host privilege, against the field `runs_on` rather than against the path. A build step refused that way while declaring no `runs_on` of its own is worth re-reading `context:` and `dockerfile:` for.

A build step does not read the checkout the run's other stages share: it runs in its own namespace and fetches the commit itself, which is why its paths are repository paths and not paths into the run's workspace.

### What every step carries

A step's container mounts the run's workspace at `/workspace` and runs with these variables set: `ANKRA_RUN_ID`, `ANKRA_STEP_ID`, `ANKRA_STEP_KEY`, `ANKRA_STEP_KIND`, `ANKRA_ORGANISATION_ID`, `ANKRA_CLUSTER_ID`, `ANKRA_REPOSITORY_ID`, `ANKRA_APPLICATION_ID`, `ANKRA_HEAD_SHA`, `ANKRA_REF`, `ANKRA_IS_FORK` and `ANKRA_WORKSPACE`. `ANKRA_OUTPUT` names a file on a memory-backed volume: append `key=value` lines to it (at most 64 KiB) and they are recorded as the step's outputs when the script exits, whether it succeeded or not. A declared secret or credential the step may read is delivered as a file under `/run/agent-secrets`, named after the binding: the agent reveals it once, at step start, by calling back with its own session token, so the value never rides in the payload the platform sent or in a log line. The directory is mounted only when the step has at least one file binding, so a step with none has no `/run/agent-secrets` to list rather than an empty one. A `secrets` binding sourced `app_env_secret`, `org_variable` or `registry` resolves; `from: credential` validates but is not deliverable yet. A `credentials` binding sourced `git` or `registry` resolves; `cloud` and `object_storage` are refused by name until the run has its own scoped identity.

### A registry login in a step

A binding sourced `registry` is delivered as a Docker `config.json` - `auths` keyed on the registry's host, carrying the username and password Ankra resolved - rather than as a bare token. `from: registry` naming no `key` is the organisation's own Ankra registry, logged in as the organisation's CI robot, which pushes and pulls across the whole organisation project; a `key` names another connected registry instead. `ankra application publish-readiness` prints the host and project the organisation's registry resolves to.

The file is named after the binding like any other, so it lands at `/run/agent-secrets/<name>` and not at a path a container tool searches by default. `DOCKER_CONFIG` is reserved - along with `PATH`, `HOME`, `TMPDIR`, `BUILDKIT_HOST` and the other variables that decide what a step actually executes - so a pipeline cannot point a tool at the directory with `env`, and declaring one of them refuses the step rather than being overwritten silently. Every tool that reads a Docker config has to be given the path explicitly: `helm --registry-config`, `oras --registry-config`, `crane --docker-config`, `skopeo --authfile`.

That is the whole of publishing a Helm chart to the organisation's registry from a pipeline, which `build` and `publish` do not cover - those stages carry an image digest, not a chart:

```yaml theme={null}
secrets:
  - name: registry_login
    from: registry

stages:
  - name: publish-chart
    image: alpine/helm:3.16.3
    network: egress-https
    secrets: ["registry_login"]
    run: |
      helm package charts/myapp --destination /tmp
      helm push /tmp/myapp-*.tgz \
        "oci://registry.ankra.cloud/org-${ANKRA_ORGANISATION_ID}" \
        --registry-config /run/agent-secrets/registry_login
```

The image is named on the stage because a pipeline's `defaults.image` is its build toolchain and ships no `helm`, and the tier is named because a `run` stage defaults to `network: none` - without it the push fails on a connection timeout rather than on the login. A registry on a private address needs its range in [`ci_egress_allowed_cidrs`](https://docs.ankra.ai/guides/ankra-pipelines#organisation-ci-settings) as well.

`secrets` is a protected section, so the binding a run receives is the one on the **default branch's approved definition**. A pull request that adds the declaration plans its logic from its own head sha but its authority from the default branch, so that first run reaches the stage with no login and the push fails to authenticate. Merge the declaration, then push the chart.

### A git credential in a step

A `credentials` binding sourced `git` is delivered as one line of JSON - not as a bare token, and not as a `git-credentials` line:

```json theme={null}
{"provider":"github","instance_url":"","username":"x-access-token","token":"<token>","expires_at":"<RFC 3339 time>"}
```

The keys are exactly these five, lower case and unnested, and the file holds that one compact line with no trailing newline. `provider` is the repository's provider: `github`, `gitlab` or `bitbucket_cloud`. `token` is a short-lived token for the run's own repository: a GitHub App installation token when an installation covers it, otherwise the stored credential the binding's `name` points at. `username` is the one the provider expects beside it (`x-access-token` on GitHub, `x-token-auth` on Bitbucket Cloud). `instance_url` is empty for a provider's public host and names a self-hosted instance otherwise. `expires_at` is a UTC RFC 3339 time for a minted token and empty for a stored credential, so a script should not depend on it. The file lands at `/run/agent-secrets/<as>`, or at `/run/agent-secrets/<name>` when the binding has no `as`, and the step's own user can read it. It is written once, at step start, and is not refreshed.

Git reads none of that by itself. Pointing `git-credential-store` at the file (`credential.helper 'store --file /run/agent-secrets/<as>'`) does not work: the store helper reads `https://user:token@host` lines, finds none, and git falls back to prompting, which a step cannot answer. The fetch exits 128 with `could not read Username for 'https://github.com': terminal prompts disabled`. Give git a helper that reads the JSON instead, and pass it on the command line: it then applies to that one command, and nothing is written under a `HOME` on the step's read-only root filesystem.

```yaml theme={null}
credentials:
  - name: github-app-myorg
    from: git
    as: repo_git

stages:
  - name: checkout
    kind: checkout
  - name: compare-with-main
    kind: run
    image: alpine/git:2.54.0
    network: egress-https
    needs: ["checkout"]
    run: |
      cat > /tmp/git-credential-ankra <<'EOF'
      [ "$1" = get ] || exit 0
      host=""
      while IFS='=' read -r key value; do
        [ -z "$key" ] && break
        [ "$key" = host ] && host="$value"
      done
      [ "$host" = github.com ] || exit 0
      file=/run/agent-secrets/repo_git
      token=$(grep -o '"token": *"[^"]*"' "$file" | cut -d'"' -f4)
      username=$(grep -o '"username": *"[^"]*"' "$file" | cut -d'"' -f4)
      [ -n "$token" ] || exit 0
      printf 'username=%s\npassword=%s\n' "${username:-x-access-token}" "$token"
      EOF
      git -c safe.directory=/workspace \
        -c credential.helper= -c credential.helper='!sh /tmp/git-credential-ankra' \
        -C /workspace fetch --no-tags origin main
```

`safe.directory` comes first because git refuses the workspace without it. Every step runs as an unprivileged user (uid 65532) while the workspace's mount directory belongs to root, so git stops with `detected dubious ownership in repository at '/workspace'` and exits 128 before it reaches the network or the helper. That applies to every git command in the stage, including read-only ones: a `git rev-parse origin/main` or `git log` without it fails the same way, which reads like a missing ref. The `checkout` stage trusts the directory for itself, and that setting is not carried to later stages either.

The empty `credential.helper=` clears any helper the image configures, and the host check keeps the token from being offered to any other host git asks about - a submodule URL, say. Replace `github.com` with your provider's host for a self-hosted instance. The image only needs `sh`, `cat`, `git`, `grep` and `cut`, and has to be inside the organisation's image policy. The tier is named because a `run` stage defaults to `network: none`, where the fetch fails on the connection rather than on the login. Git exits 128 for an untrusted workspace, a blocked connection and a missing login alike, so check them in that order.

The `checkout` stage's own credential helper is not available to later stages, on purpose. It is configured under a `HOME` on that step's own `/tmp`, which no other step mounts, so a later step cannot inherit it or rewrite it for a checkout that runs after it. What later steps do share is the workspace, whose `origin` remote is the plain clone URL with no credential in it. A step that fetches from `origin` again needs its own `credentials` binding, `safe.directory`, and a helper like the one above.

`credentials` is protected like `secrets`: a pull request run receives the block on the default branch's approved definition, not the one on its own head.

## Expressions

Any string field may embed expressions between `${{` and `}}`; the text inside is [CEL](https://cel.dev). Evaluation is deterministic and performs no I/O: everything an expression can reach arrives in its context, and `hashFiles` is served by the agent, which has the workspace.

| Context | Paths |
| - | - |
| `ankra` | `organisation`, `repository.owner`, `repository.name`, `repository.provider` (the stored provider: `github`, `gitlab` or `bitbucket_cloud`), `application` (empty for a bare repository), `ref` (the full git reference the run identifies itself by: `refs/heads/main` for a push, `refs/tags/v1.2.0` for a tag, and `refs/pull/<n>/head` for a pull request, so a concurrency group keyed on it serialises each pull request on its own; the base branch a pull request targets is `event.ref`), `sha`, `sha_short` (7 characters), `run.id`, `run.number`, `environment.name`, `environment.url`, `urls.run`, `urls.portal` |
| `event` | `kind` (`push`, `pull_request`, `tag`, `schedule`, `manual`, `api`), `provider` (`github`, `gitlab`, `bitbucket` - Bitbucket Cloud is `bitbucket` here, where `ankra.repository.provider` spells it `bitbucket_cloud`), `pull_request.number`, `pull_request.is_fork`, `pull_request.head_repo`, `ref`, `before_sha`, `changed_files` |
| `inputs.<name>` | The validated dispatch inputs |
| `matrix.<name>` | The current matrix leg |
| `vars.<name>` | Organisation, cluster and environment variables |
| `env.<NAME>` | Environment variables in scope for the step |
| `steps.<key>.outputs.<name>`, `steps.<key>.outcome` | Concluded steps of this run |
| `needs.<stage>.outputs.<name>`, `needs.<stage>.result` | The stages this one depends on |
| `secrets.<NAME>` | `true` when the secret is declared - never its value, so no expression can leak one into a log line or a check-run summary |

Functions: `hashFiles(pattern, …)`, `contains(haystack, needle)` (substring or list membership), `startsWith`, `endsWith`, `fromJSON`, `toJSON` (compact, keys sorted), `format(template, args…)` with GitHub-style `{0}` placeholders, `join(list)` and `join(list, separator)`, and the status functions `always()`, `success()`, `failure()`, `cancelled()` and `skipped()`. The CEL standard library - `size`, `matches`, the bounded `all`/`exists`/`map`/`filter` macros, arithmetic and comparisons - is available on top.

Rules worth knowing:

* A stage condition is compiled at plan time against a schema without `steps.*`, so a reference to a step that has not run is deferred rather than resolved to nothing. The dispatcher evaluates the same condition with the run's concluded steps in scope, and interpolates the same run's `${{ }}` references into `run`, `env.*`, `with.*`, `image`, `working_directory` and the stage's `outputs:`. Dispatch is the last evaluation point there is, so unlike plan time nothing unresolved is carried through verbatim: a field that still will not resolve concludes the step `infra_error` naming it, rather than handing a container a literal `${{ }}`.
* **A step key nobody declared resolves to empty, not to an error.** The expression schema declares *roots*, not key sets, and a step key is a matrix-expansion product the validator cannot know - so `${{ steps.buidl.outputs.tag }}` interpolates to an empty string. Check the key against the stage names you wrote; nothing else will.
* `has()` is refused on `inputs`, `matrix`, `vars`, `env`, `steps`, `needs` and `secrets`: an absent key on those roots resolves to the zero value, so a converted `env.DEPLOY == 'true'` keeps its meaning when the variable is unset. Compare the value instead. `has()` still works on `ankra` and `event`, whose paths are fixed.
* Converted GitHub expressions keep working - single-quoted strings, `==`, `!=`, `&&`, `||`, `!`, the functions above, and the `steps`, `matrix`, `inputs`, `env` and `needs` references map one to one. A reference to the `github`, `runner`, `job` or `strategy` context is a diagnostic naming the Ankra equivalent (`github.sha` is `ankra.sha`, `github.event_name` is `event.kind`). Two deliberate departures: `toJSON` emits compact JSON, and comparisons follow the typed rules of CEL, so `'1' == 1` is a type error rather than true.
* A template is at most 8,192 bytes with at most 64 expressions; an expression is at most 2,048 bytes and 24 levels deep; the evaluation cost is capped. Each bound is refused with a fixed sentence at validation time.

## Triggers and reasons

Every event that reaches the trigger is decided in order, and the answer is recorded as one of these tokens. Where a run row is written, `ankra pipeline list` shows it; where none is, the reason is in the platform's logs and metrics.

| Reason | Meaning | Run recorded |
| - | - | - |
| `enqueued` | One run was queued | Yes |
| `already_queued` | A run for this event is still queued; the redelivery joined it | No new run |
| `already_recorded` | A run for the same commit, trigger, reference and pull request exists inside the recent-run window - running, or concluded skipped; the redelivery joined it | No new run |
| `skipped` | The trigger matched but its branch, tag or path filter excluded the event, or a pushed commit carries a [skip marker](#skipping-a-push-with-skip-ci) the organisation honours on that branch | Yes, concluded with outcome `skipped` and the planner's sentence, or error class `skip_marker` and a sentence naming the marker |
| `refused` | A dispatch the planner refused - an undeclared `on.manual`, an input outside its declared type | No; the diagnostics go back to the caller |
| `repository_not_onboarded` | The repository is not connected to a pipeline | No |
| `no_ci_cluster` | The organisation has no CI cluster and no AI staging cluster | No, for a provider event; a dispatch still gets its run row |
| `no_definition` | The repository has no stored definition | No |
| `trigger_not_declared` | The definition's `on:` block does not declare this event kind | No |
| `fork_policy_pending` | Reserved in the metric's stable label set; no longer returned in the ordinary case. A pull request whose head lives in a fork, or whose provenance could not be established, is enqueued and runs restricted today - see [Forks](https://docs.ankra.ai/guides/ankra-pipelines.md#what-a-step-runs-as) | No |

Dispatches (`manual`, `api`, `agent`, `rerun`) are exempt from the recent-run deduplication: a person who presses run twice asked for two runs.

A dispatch is also the one entry point whose commit no provider delivery has already proven, so `POST …/pipeline-runs` resolves the `ref` it was given - or the repository's default branch - against the repository's own host and accepts a supplied `head_sha` only when it is that reference's current tip or, on GitHub, a provable ancestor of it. Anything else refuses the dispatch before a run row exists, and so does a host that could not be asked at all. A `rerun` is exempt: its commit is one Ankra itself recorded on the original run, which is what lets a fork pull request's run be repeated when the commit lives on no ref of this repository.

Inside a run, a stage that is not planned carries a skip reason: `kind_unavailable` (the stage's kind has no executor on this build - decided first, because it is a fact about the stage rather than about the event), `event_filter`, `branch_filter`, `path_filter`, `schedule_subset`, `condition_false`, `condition_unreadable` (a condition nobody can read is not a condition that passed), `fork_policy`, `dependency_skipped`, `deploy_untrusted_event` (a `deploy` stage in a pull request run, or in a run of a head the repository does not own) or `promoted` (a push run publishes the image a pull request or merge train run already built and tested for the same tree, so this stage, which that run passed, is not run again; see [promotion](#promote-a-tested-image-instead-of-rebuilding)). A run refused as a whole carries one fixed sentence, for example "Every stage this pipeline declares is filtered out for this event, so the run would do nothing." or "A stage fans out over more matrix legs than the number one run may plan."

A step that *was* planned can still be concluded without running. At dispatch, a deferred `if:` that evaluates false concludes the step `skipped` with the condition in the message, and a `${{ }}` reference dispatch cannot resolve concludes it `infra_error` naming the field. A step still `pending` once its run has outlived the sum of every step's `timeout` is concluded `infra_error` with error class `step_stranded`, never retried. A `kind: scan` step is refused at dispatch with error class `artifact_store_unavailable` - "This step's reports need a ready backup vault in the organisation, and there is none." - when its reports would have nowhere to go: no ready vault in the organisation *and* no Ankra-managed default vault designated. An organisation with no vault of its own ordinarily falls back to the platform's, so this is now the rare case rather than the first-run one. A `scan` step can also be concluded and then *revised*: one that succeeded but whose declared report Ankra could not read becomes `infra_error` with error class `report_unreadable`, because an unread scan is not a clean one - see [When something fails](https://docs.ankra.ai/guides/ankra-pipelines.md#when-something-fails).

## Time saved

Every run records the CI time it did not spend because of a path filter, the dependency graph or a promoted publish, with an estimate for each piece of work it skipped. Only runs planned after this was released have a record. Older runs show nothing.

### What counts

| Saving | `reason` |
| - | - |
| A stage left out by a path filter (`when.paths`, the event block's `paths`, or the stage's affected-only `paths:`) | `path_filter` |
| A stage skipped because a stage it needs was left out by a path filter. `cause_stage` names that stage | `dependency_skipped` |
| A step a promoted publish (`publish.promote_from`) did not run again | `promoted` |
| A step skipped as not affected by its stage's [`affected:`](https://docs.ankra.ai/guides/ankra-pipelines#affected-from-the-dependency-graph-affected) dependency graph | `not_affected` |
| A whole run that `on.<event>.paths` excluded | `run_paths_excluded` |

A stage skipped by a branch filter or a condition is not counted, because it would not have run whatever the change touched. An `on_failure` stage is never counted.

### How a saving is estimated

Each estimate is made when the run is planned or skipped, or for a `not_affected` step when the step is skipped, from the history of the same repository.

| Saving | Estimate | `basis` |
| - | - | - |
| A skipped stage | The median time of the stage's newest 20 successful runs, read from the repository's newest 300 concluded runs. A matrix stage adds up its legs | `stage_history` |
| A step skipped as not affected | The same median: from the stage's history when the stage has one step, or from the leg's own newest 20 successful runs for a matrix leg | `stage_history` or `step_history` |
| A promoted step | The time the same step took in the run it was promoted from. When that run no longer has the step, or the step did not succeed there, the median of its own newest 20 successful runs: `step_history` for a matrix leg, `stage_history` for a stage with one step | `promoted_source_run`, else `step_history` or `stage_history` |
| A whole run | The median total step time of the newest 20 successful runs of the same trigger | `run_history` |
| Work with no usable history | Unknown. `saved_seconds` is null and the saving is not added to any total | `no_history` |

The time is runner time, the seconds the skipped steps would have run added up. It is not wall-clock time, so two skipped stages that would have run side by side count twice. CPU-minutes are the requested CPU times that duration. A sample whose steps did not all carry a CPU request is left out of the CPU median, and CPU-minutes are null when no sample carried one. A promoted step estimated from its source run has CPU-minutes when the step carried a request there.

### Where to read it

* `ankra pipeline get <run-id>` prints a `Time saved` line under the run's steps, with one line per saving. It prints nothing for a run with no savings. With `-o json` or `-o yaml` the run's record is added as the `time_saved` field. A run with no record, including one planned before this was released, carries a `time_saved` object with `saved_seconds` 0 and an empty `savings` list. `time_saved` is null only when the platform does not serve the route yet or the read failed, and a failed read is reported on stderr.
* `ankra pipeline time-saved` prints the total for the organisation over the last 30 days. `--window 7d` reads the last 7 days, and `--application` or `--repository` reads one application or repository.
* The API has three routes. `GET …/pipeline-runs/{run_id}/time-saved` reads one run. `GET …/pipeline-time-saved?window=30d` reads one repository or application. `GET /api/v1/org/pipelines/time-saved?window=30d` reads the organisation. `window` is `7d` or `30d` and defaults to `30d`.

## Validation

```bash theme={null}
ankra pipeline validate --application my-service
ankra pipeline validate path/to/pipeline.yaml --application my-service
```

The command reads `.ankra/pipeline.yaml` (or the file you name), validates it, and plans it for a synthetic push to the default branch and a synthetic pull request - and a synthetic `v1.0.0` tag when the pipeline declares `on.tag` - printing for each the steps that would run, the stages that would be skipped with their reason, and every diagnostic, then a table of every stage against every event: `run`, or `skip` with the reason. A path filter cannot decide without a diff, so it passes with a diagnostic; `--changed-file <path>` (repeatable) plans the push and the pull request as if those files changed, so path filters - flat or [event-scoped](#event-scoped-filters) - decide for real (`changed_files` in the API body):

```bash theme={null}
ankra pipeline validate --application my-service --changed-file frontend/app.ts --changed-file .ankra/pipeline.yaml
```

Its exit code is non-zero when the severity is `fatal`. Without a file and without a stored definition there is nothing to validate. The same validator answers `POST …/pipeline/validate` on the API and the `cicd_validate_pipeline_yaml` tool in chat, which needs no repository at all.

Every planned step of that dry run carries the [network tier it resolved to](#the-tier-a-stage-gets-by-default) - `network` on each node of the DAG - so a definition that names no tier anywhere still says what each of its stages will be allowed to reach. The field is on the API's answer today; the CLI's own step line prints the step key, the stage and the kind only.

A result has a severity - `ok`, `advice` (the pipeline does what it says, and there is a better way to say it), `warn` (the pipeline runs, but not the way its author probably meant; reported on the pull request, never blocking) or `fatal` (the pipeline cannot be planned) - and a list of violations, each prefixed with a fixed key. Only `fatal` stops a run, and only `fatal` makes the command exit non-zero:

| Key | Covers |
| - | - |
| `pipeline_parse:` | A document that is not readable YAML, or whose shape the decoder could not take |
| `pipeline_unknown_key:` | A key this reference does not define, at the top level or in any block (warning). The sentence names the line, the key, the block it sits in and - when the key is close enough to be a slip rather than an invention - the field it was probably meant to be. The key is ignored, so the value written against it has no effect |
| `pipeline_kind:` | A wrong `apiVersion` or `kind` |
| `pipeline_metadata:` | A missing `metadata.name` |
| `pipeline_stages:` | The stage list and stage identity: missing or duplicate names, an unusable name, both `allow_failure` and `continue_on_error` (warning) |
| `pipeline_stage_kind:` | A kind outside the vocabulary, or a kind missing what it requires (`run` without `run` or `uses`, `approval` without roles, `agent` without a goal, `webhook` without a URL, `external` without a provider, `verify` without a sub-block) |
| `pipeline_executor:` | A stage whose kind has no executor on this build (warning). The sentence names the stage, the kind and the wave that ships its executor; the planner skips that stage with reason `kind_unavailable` |
| `pipeline_uses:` | A `uses` reference not spelled as one of the three forms |
| `pipeline_uses_unavailable:` | Any `uses` at all, correctly spelled or not, until the step library ships (fatal) |
| `pipeline_needs:`, `pipeline_cycle:` | A `needs` entry naming no stage; a stage needing itself; a cycle |
| `pipeline_trigger:` | The `on:` block: an unknown `fork_policy`; `cancel_in_progress` without a group (warning) |
| `pipeline_concurrency_queues_behind:` | A `concurrency.group` that varies per branch or pull request with no `cancel_in_progress` (advice), so a newer commit waits for the older run - see [Newest commit wins](https://docs.ankra.ai/guides/ankra-pipelines.md#newest-commit-wins) |
| `pipeline_schedule:` | A cron that is not five numeric fields; a schedule naming a stage that does not exist. Warnings: a schedule's `stages` leaving out a stage one of them needs; an entry that an earlier entry with the same cron always wins over (the earlier one names the same branch, or no branch) |
| `pipeline_inputs:` | Input names, types, a `choice` without an enum, a default outside the enum |
| `pipeline_secrets:`, `pipeline_credentials:`, `pipeline_permissions:` | An unknown source, a duplicate name, an unknown permission level, a stage naming an undeclared secret |
| `pipeline_services:` | A service without an image; a stage naming an undeclared service |
| `pipeline_expression:` | An unterminated `${{` in `if`, `run`, `uses`, `image`, `timeout`, `with` or `env` (a stray `}}` is not reported: scripts legitimately carry them) |
| `pipeline_matrix:` | An axis with no values; an exclusion on an axis that does not exist (warning) |
| `pipeline_network:` | A tier outside `none`, `egress-https`, `services`; an explicit `network: "none"` on a `checkout`, `build` or `scan` stage (warning), naming what that stage can no longer reach |
| `pipeline_retry:` | More than 5 attempts, a negative count, an unknown backoff or condition |
| `pipeline_artifacts:` | An artifact without a name or paths; a negative retention (warning); an unknown test result format or a missing path |
| `pipeline_environment:` | A stage targeting an environment the pipeline does not declare (warning) |
| `pipeline_paths:` | An `affected` block that names no package, a pattern it cannot read (an expression, a path above the repository root, a wildcard other than a trailing `/...`), or relative patterns under a `working_directory` that carries an expression (fatal); an `affected` stage in a pipeline with no checkout stage (warning: the graph is never read, so the stage always runs). An [affected-only](#fields-common-to-every-stage) stage (`paths` or `affected`) on a shape where "satisfied" would be untrue: a stage reading its outputs (`needs.<stage>.outputs`, `steps.<stage>.outputs`, a deploy whose `artifact` names it), a `build` another stage needs, a `checkout`, `gate` or `approval`, a scan a gate requires, an empty pattern, or `paths` beside a `when` path filter (fatal). Warnings: an affected-only scan no gate requires, an affected-only `deploy`, `publish` or `preview` that another stage needs, a `skippable_needs` entry the needed stage's own `paths` already covers, and any path pattern, in a path list or a [path set](#path-sets), spelled so that it can match no file (a leading `./` or `/`, a trailing `/`, a backslash or surrounding whitespace). A path set's entry is reported once, at `path_sets.<name>[i]` |
| `pipeline_path_sets:` | The [`path_sets:`](#path-sets) block and its `{set: <name>}` references: a reference to an undeclared set, sets that include each other in a cycle, a reference in a list that is not a path filter, an invalid or duplicate set name, an entry that is neither a pattern nor a reference, an empty pattern (fatal). Warnings: a set nothing references, an empty set |
| `pipeline_full_run:` | A [`full_run`](#setting-path-filters-aside) block naming an event no run is planned as, or naming no event and no branch, which sets every path filter aside on every run (both warnings) |
| `pipeline_build_component:` | A `build.component` that is not a valid component name; one on a stage that is not `kind: build` (warning). Validated against an application: a build stage that belongs to no component, a `build.component` the application does not record, and two build stages that publish to the same repository - fatal when both always run, a warning when one of them runs only under a condition. See [Which component a build belongs to](https://docs.ankra.ai/guides/ankra-pipelines.md#which-component-a-build-belongs-to) and the application's [declared components](https://docs.ankra.ai/reference/application-descriptor.md#components) |

The planner adds diagnostics with `plan_` keys (`plan_trigger:`, `plan_paths:`, `plan_paths_set_aside:`, `plan_matrix:`, `plan_condition:`, `plan_fork:`, `plan_fork_budget:`, `plan_concurrency:`, `plan_interpolation:`, `plan_timeout:`, `plan_needs:`, `plan_spec:`), and the step renderer records what it decided on incomplete information: `image_policy_open` when the organisation set no image policy, `resources_defaulted`, `service_without_readiness`, `service_undeclared`, `build_without_registry_auth`, `registry_push_withheld`, `build_namespace_workspace` and `multi_platform_build`. A step the renderer refuses outright - an image outside the policy, more compute than one step may have, a missing timeout, a `services` tier without services, host privilege - is a fixed sentence naming the field, and the same refusal at dispatch time is what a step that never started shows.

## A complete example

This is the pipeline Ankra's own documentation repository is written against - a bare repository with nothing to build, which is the case that proves pipelines are repository-scoped with an optional application link.

```yaml theme={null}
# ankraio/ankra-docs as an Ankra pipeline.
apiVersion: ankra.io/v1
kind: Pipeline
metadata:
  name: "ankra-docs"
on:
  push:
    branches:
      - "master"
  pull_request:
    branches:
      - "master"
    fork_policy: "read_only"
  schedule:
    - cron: "0 6 * * *"
      branch: "master"
      stages:
        - "checkout"
        - "install"
        - "broken-links"
        - "live-openapi-reachable"
concurrency:
  group: "ankra-docs-${{ ankra.ref }}"
  cancel_in_progress: true
workspace:
  size: "10Gi"
  access: "rwo"
defaults:
  image: "node:22-bookworm-slim"
  timeout: "15m"
  network: "egress-https"
permissions:
  contents: "read"
  pipelines: "read"
stages:
  - name: "checkout"
    kind: "checkout"

  - name: "install"
    kind: "run"
    run: |-
      set -eu
      corepack enable
      pnpm install --frozen-lockfile
    needs:
      - "checkout"
    cache:
      - key: "pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}"
        paths:
          - ".pnpm-store"
    timeout: "10m"

  # The structure checks read the repository and nothing else; only the
  # pyyaml install needs egress.
  - name: "structure"
    kind: "run"
    image: "python:3.12-slim"
    run: |-
      set -eu
      pip install --quiet pyyaml
      python3 scripts/check_nav.py
      python3 scripts/check_frontmatter.py
      python3 scripts/check_snippets.py
    needs:
      - "checkout"
    timeout: "10m"

  # Split out because it is the only check that leaves the repository: it
  # fails on production being down rather than on the docs being wrong, and
  # as its own stage that distinction is visible on the run.
  - name: "live-openapi-reachable"
    kind: "verify"
    needs:
      - "checkout"
    verify:
      probe:
        url: "https://platform.ankra.app/openapi.json"
        status: 200
        timeout: "1m"

  - name: "broken-links"
    kind: "run"
    run: |-
      set -eu
      pnpm exec mint broken-links
    needs:
      - "install"
    timeout: "15m"

  # allow_failure keeps the run green while the prose warnings stay visible
  # on the stage.
  - name: "prose"
    kind: "run"
    image: "jdkato/vale:v3.9.1"
    run: |-
      set -eu
      vale --output=line --minAlertLevel=warning .
    needs:
      - "checkout"
    allow_failure: true
    timeout: "10m"

on_failure:
  - name: "triage"
    kind: "agent"
    agent:
      goal: "Explain which docs check failed and propose the smallest edit that fixes it."
      tool_profile: "ci-read-only"
      mode: "ask"
      budget:
        tool_calls: 15
        turns: 6
        minutes: 10
      context:
        - "diff"
        - "logs"

finally:
  # A Slack incoming-webhook URL is a bearer credential, so it is read from
  # the organisation's variables rather than committed here.
  - name: "notify"
    kind: "webhook"
    webhook:
      url: "${{ vars.SLACK_CI_ALERTS_WEBHOOK }}"
      method: "POST"
      timeout: "30s"
```

Reading it against [what executes today](https://docs.ankra.ai/guides/ankra-pipelines.md#what-is-in-place-today): `checkout` and the four `run` stages execute in the cluster; the `verify` probe, the `agent` triage and the `webhook` notification have no executor on this build, so each reports a `pipeline_executor:` warning and is skipped with reason `kind_unavailable` - the run still concludes on the stages that do execute. The schedule fires. The definition therefore validates as `warn`, not `ok`, and that is the honest answer: the file describes three stages the platform will not run yet, and says so in the same words the run's own skip reason uses.

## Related

> **[Ankra Pipelines](https://docs.ankra.ai/guides/ankra-pipelines.md)**
>
> How a run happens, the security model, failure handling and limits.

> **[Migrate from GitHub Actions](https://docs.ankra.ai/guides/migrate-from-github-actions.md)**
>
> Convert an existing definition and review the notes.

> **[ankra.yaml reference](https://docs.ankra.ai/reference/application-descriptor.md)**
>
> The application descriptor beside the pipeline: metadata, deploy options and declared components.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.