Skip to main content
.ankra/pipeline.yaml is the one file an Ankra pipeline is planned from. It sits beside .ankra/ankra.yaml 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 - always comes from the default branch’s approved definition, whichever source the logic came from, an override included.

The document

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

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 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

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. 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 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 paths) and an affected-only stage’s paths: all pass. A run is a full run when one of these asks for it:
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 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: 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: 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. 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 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.
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.

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.
  • 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

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. 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.
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. 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. 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: 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

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:
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 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: 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, 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:
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:
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 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:
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.
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. 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. 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. 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). 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.

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

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. 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

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 - decide for real (changed_files in the API body):
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 - 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: 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.
Reading it against what executes 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.

Ankra Pipelines

How a run happens, the security model, failure handling and limits.

Migrate from GitHub Actions

Convert an existing definition and review the notes.

ankra.yaml reference

The application descriptor beside the pipeline: metadata, deploy options and declared components.