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

# Speed Up CI

> Run only the work a change needs, and publish the image that was already tested. Ready-to-paste pipeline.yaml examples and real timings from Ankra's own pipelines.

export const CliVersion = ({since, command, note}) => {
  const latestStableCli = "0.33.0";
  const parse = version => String(version).split(".").map(part => parseInt(part, 10) || 0);
  const requested = parse(since);
  const stable = parse(latestStableCli);
  let isPrerelease = false;
  for (let index = 0; index < 3; index += 1) {
    if (requested[index] > stable[index]) {
      isPrerelease = true;
      break;
    }
    if (requested[index] < stable[index]) {
      break;
    }
  }
  const containerStyle = {
    display: "flex",
    alignItems: "baseline",
    gap: "0.6rem",
    margin: "1rem 0",
    padding: "0.6rem 0.9rem",
    border: "1px solid rgba(128, 128, 128, 0.35)",
    borderRadius: "0.5rem",
    fontSize: "0.9em",
    lineHeight: 1.5
  };
  const pillStyle = {
    flex: "none",
    padding: "0.1rem 0.5rem",
    borderRadius: "999px",
    background: "rgba(128, 128, 128, 0.18)",
    fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
    fontSize: "0.85em",
    fontWeight: 600,
    whiteSpace: "nowrap"
  };
  const keepTogether = {
    whiteSpace: "nowrap"
  };
  return <div style={containerStyle} data-cli-version={since}>
      <span style={pillStyle}>CLI v{since}+</span>
      <span>
        {command ? <span>
            <span style={keepTogether}>
              <code>ankra {command}</code>
            </span>{" "}
            needs
          </span> : <span>The commands on this page need</span>}{" "}
        the ankra CLI <strong style={keepTogether}>v{since} or later</strong>
        {isPrerelease ? <span>
            {" "}
            - a pre-release today, so enable the{" "}
            <a href="/integrations/ankra-cli#beta-pre-release-channel">beta channel</a> before
            upgrading
          </span> : null}
        . Check yours with{" "}
        <span style={keepTogether}>
          <code>ankra --version</code>
        </span>
        ; <a href="/integrations/ankra-cli#upgrading-the-cli">upgrade</a> with{" "}
        <span style={keepTogether}>
          <code>ankra upgrade</code>
        </span>
        .{note ? <span> {note}</span> : null}
      </span>
    </div>;
};

Most CI time goes on work a change did not need: backend tests for a frontend fix, or a second build of a commit its pull request already built and tested. Ankra Pipelines skips that work with a few lines of `.ankra/pipeline.yaml`, and never skips anything it cannot prove the change left alone.

<Frame>
  <img src="https://mintcdn.com/ankra/YXyjjLuuqrU4Bw7Z/images/use-cases/speed-up-ci.svg?fit=max&auto=format&n=YXyjjLuuqrU4Bw7Z&q=85&s=9b49b3ed5618c65d8e883d54e2572830" alt="Four steps. A pull request runs only the stages its files touch. The image is built once, scanned and gated by digest. The merge train tests the pull request as it will merge. Publish re-tags the tested image instead of building it again. Below them: if Ankra cannot see every changed file, nothing is skipped." className="w-full" width="820" height="236" data-path="images/use-cases/speed-up-ci.svg" />
</Frame>

## What it saves

Timings from Ankra's own pipelines.

<CardGroup cols={3}>
  <Card title="15 seconds from merge to published" icon="rocket">
    A TypeScript frontend with 20-minute pull request runs. Its last 40 merges re-tagged the image the pull request had already built and tested.
  </Card>

  <Card title="About 3 minutes instead of 19" icon="stopwatch">
    A Go and Next.js repository. A pull request that does not touch the backend skips its 17 minutes of tests.
  </Card>

  <Card title="About 60 CPU minutes per merge" icon="microchip">
    A Go, TypeScript and Rust monorepo. Three rarely touched stages, one a 10-minute suite, now run only when their files change. Estimated from stage timings.
  </Card>
</CardGroup>

## Which technique to use

| You want to | Use |
| - | - |
| Run nothing when a change only touches documentation | [`on.<event>.paths`](#skip-documentation-only-changes) |
| Run a pull request's checks only for what it touches, and everything on main | [`when.pull_request`](#run-only-the-tests-a-change-touches) |
| Skip a component's tests on every event when its files did not change | [`paths:` on the stage](#run-only-the-tests-a-change-touches) |
| Skip a component's tests when nothing it imports changed, across modules and workspace packages | [`affected:` on the stage](#run-only-the-tests-a-change-touches) |
| Stop building and testing a commit again after its pull request did | [`promote_from`](#build-once-and-promote-on-merge) |
| Test each pull request on top of the latest main | [Merge train](#test-each-pull-request-as-it-will-merge) |
| Run slow suites less often | [`on.schedule`](#run-slow-suites-nightly) |
| Write a list of paths once and use it on many stages | [`path_sets`](#write-each-path-list-once) |
| Run everything anyway, or keep a stage from ever being skipped | [Full runs and always-run stages](#run-everything-when-you-need-to) |

They combine. A typical setup is pull-request-only filters on the expensive tests, `promote_from` on `publish`, and the merge train on the default branch.

## Skip documentation-only changes

A trigger `paths` list that starts with `!` means "everything except". A change that only touches the excluded files runs no stage, and the run is recorded as skipped. Name the list once in `path_sets:` and both triggers use it.

```yaml theme={null}
path_sets:
  not-docs: ["!docs/**", "!*.md", "!.github/**"]
on:
  push:
    branches: [main]
    paths: {set: not-docs}
  pull_request:
    branches: [main]
    paths: {set: not-docs}
```

A pull request that changes only `docs/intro.md` and `README.md` is skipped with the reason "Every file this event changed falls outside the paths this pipeline's trigger declares." One that also changes `api/README.md` runs, because `*.md` matches only files at the repository root.

<AccordionGroup>
  <Accordion title="Full pipeline.yaml">
    ```yaml theme={null}
    apiVersion: ankra.io/v1
    kind: Pipeline
    metadata:
      name: shop
    path_sets:
      not-docs: ["!docs/**", "!*.md", "!.github/**"]
    on:
      push:
        branches: [main]
        paths: {set: not-docs}
      pull_request:
        branches: [main]
        paths: {set: not-docs}
    defaults:
      network: egress-https
    stages:
      - name: checkout
        kind: checkout
      - name: test
        kind: run
        image: golang:1.26
        run: go test ./...
        needs: [checkout]
    ```
  </Accordion>

  <Accordion title="How path patterns match">
    * Patterns match file paths from the repository root: `api/**`, never `./api/**` or `/api/**`.
    * `*` matches any characters except `/`, `**` matches any characters including `/`, and `?` matches one character.
    * A pattern matches whole file names, so `docs/` matches nothing. Write `docs/**`.
    * `!` removes what a pattern matches, and the last pattern that matches a file decides: `["web/**", "!web/**/*.md"]` is the web directory without its Markdown files.
    * Matching is case-sensitive.
    * Exclude a file type everywhere (`**/*.md`) only when nothing your build or tests read has that type. Code that embeds Markdown prompts or templates needs its tests to run when they change.
    * `ankra pipeline validate` warns about a pattern that can match no file (a leading `./` or `/`, a trailing `/`, a backslash) and names the pattern to write instead.
  </Accordion>
</AccordionGroup>

## Run only the tests a change touches

Filter each expensive test stage on the files it covers. Choose whether the filter applies to pull requests only or to every event.

<Tabs>
  <Tab title="Pull requests only">
    Pull requests skip the tests of components they do not touch, and every push to main still runs everything. This is the safest place to start, and the technique behind the "About 3 minutes instead of 19" example.

    ```yaml theme={null}
    - name: backend-test
      kind: run
      image: golang:1.26
      run: go test ./...
      when:
        pull_request:
          paths: ["backend/**"]
        push: {}
    ```

    A pull request that changes only `frontend/src/app.tsx` skips `backend-test` with reason `path_filter`. A stage that needs a filtered stage is skipped with it, unless it lists it in `skippable_needs` (see **Later stages and conditions**).

    <Accordion title="Full pipeline.yaml">
      ```yaml theme={null}
      apiVersion: ankra.io/v1
      kind: Pipeline
      metadata:
        name: shop
      on:
        push:
          branches: [main]
        pull_request:
          branches: [main]
      defaults:
        network: egress-https
      stages:
        - name: checkout
          kind: checkout
        - name: backend-test
          kind: run
          image: golang:1.26
          run: go test ./...
          working_directory: backend
          needs: [checkout]
          when:
            pull_request:
              paths: ["backend/**"]
            push: {}
        - name: frontend-test
          kind: run
          image: node:24
          run: corepack enable && pnpm install --frozen-lockfile && pnpm run test
          working_directory: frontend
          needs: [checkout]
          when:
            pull_request:
              paths: ["frontend/**"]
            push: {}
      ```
    </Accordion>
  </Tab>

  <Tab title="Every event">
    A stage with `paths:` is **affected-only**: when no changed file matches, it is skipped on every event, pushes included, and the stages that need it still run, each waiting on what the skipped stage waited on. The image is still built, scanned, gated and published.

    ```yaml theme={null}
    - name: test-api
      kind: run
      image: golang:1.26
      run: go test ./...
      working_directory: api
      needs: [checkout]
      paths: ["api/**", "go.work"]
    ```

    A push that changes `web/app.ts` skips `test-api`, and `build` waits for `test-web`, and for `checkout` in place of `test-api`. `ankra pipeline validate` refuses `paths:` where a skip would be unsafe: a stage whose outputs another stage reads, a `build` another stage needs, a `checkout`, `gate` or `approval`, a scan a gate judges, or a stage a release bundle build packs. It warns when an affected-only stage that another stage needs declares `artifacts:`.

    <Accordion title="Full pipeline.yaml">
      ```yaml theme={null}
      apiVersion: ankra.io/v1
      kind: Pipeline
      metadata:
        name: shop
      on:
        push:
          branches: [main]
        pull_request:
          branches: [main]
      defaults:
        network: egress-https
      stages:
        - name: checkout
          kind: checkout
        - name: test-web
          kind: run
          image: node:24
          run: corepack enable && pnpm install --frozen-lockfile && pnpm -C web run test
          needs: [checkout]
          paths: ["web/**", "packages/ui/**"]
        - name: test-api
          kind: run
          image: golang:1.26
          run: go test ./...
          working_directory: api
          needs: [checkout]
          paths: ["api/**", "go.work"]
        - name: build
          kind: build
          needs: [test-web, test-api]
          build:
            dockerfile: Dockerfile
        - name: scan
          kind: scan
          needs: [build]
          scan:
            scanners: [semgrep, trivy]
        - name: gate
          kind: gate
          needs: [scan]
        - name: publish
          kind: publish
          needs: [gate]
          when:
            events: [push]
      ```
    </Accordion>
  </Tab>

  <Tab title="From the dependency graph">
    Name the packages a stage tests instead of listing their files. Ankra follows the imports, so a change to a shared library reaches every stage that uses it, even from another Go module or pnpm workspace package, and a change to anything else skips it. Like `paths:`, the stages that need a skipped stage still run.

    ```yaml theme={null}
    - name: test-api
      kind: run
      image: golang:1.26
      run: go test ./...
      working_directory: api
      needs: [checkout]
      affected:
        go: ["./..."]
      paths: ["migrations/**"]
    ```

    A change to `libs/auth/token.go`, which `api` imports through a `replace` directive in `api/go.mod`, runs `test-api`. A change to `web/app.ts` skips it as not affected. `paths:` adds the files the tests read at run time, which no import graph sees. The run's checkout reads the graph of the exact commit it tested in a few seconds; a stage it cannot answer for runs.

    <Accordion title="Full pipeline.yaml">
      ```yaml theme={null}
      apiVersion: ankra.io/v1
      kind: Pipeline
      metadata:
        name: shop
      on:
        push:
          branches: [main]
        pull_request:
          branches: [main]
      defaults:
        network: egress-https
      stages:
        - name: checkout
          kind: checkout
        - name: test-api
          kind: run
          image: golang:1.26
          run: go test ./...
          working_directory: api
          needs: [checkout]
          affected:
            go: ["./..."]
          paths: ["migrations/**"]
        - name: test-web
          kind: run
          image: node:24
          run: corepack enable && pnpm install --frozen-lockfile && pnpm --filter @acme/web... test
          needs: [checkout]
          affected:
            pnpm: ["@acme/web"]
        - name: build
          kind: build
          needs: [test-api, test-web]
          build:
            dockerfile: Dockerfile
        - name: scan
          kind: scan
          needs: [build]
          scan:
            scanners: [semgrep, trivy]
        - name: gate
          kind: gate
          needs: [scan]
        - name: publish
          kind: publish
          needs: [gate]
          when:
            events: [push]
      ```
    </Accordion>
  </Tab>

  <Tab title="Later stages and conditions">
    `skippable_needs` keeps a later stage running when a stage it needs was skipped for its paths. `if:` covers rules a path list cannot express, using `event.changed_files` and `event.kind`.

    ```yaml theme={null}
    - name: e2e
      needs: [checkout, frontend-test]
      skippable_needs: [frontend-test]
    - name: migrations-check
      needs: [checkout]
      if: "event.kind == 'push' || event.changed_files.exists(f, f.startsWith('db/migrations/'))"
    ```

    A pull request that changes only `backend/main.go` skips `frontend-test` (`path_filter`) and `migrations-check` (`condition_false`), and still runs `e2e`. An `if:` that calls a status function such as `always()` also runs when a need was skipped, and `needs.<stage>.result` reads `skipped`.

    <Accordion title="Full pipeline.yaml">
      ```yaml theme={null}
      apiVersion: ankra.io/v1
      kind: Pipeline
      metadata:
        name: shop
      on:
        push:
          branches: [main]
        pull_request:
          branches: [main]
      defaults:
        network: egress-https
      stages:
        - name: checkout
          kind: checkout
        - name: frontend-test
          kind: run
          image: node:24
          run: corepack enable && pnpm install --frozen-lockfile && pnpm run test
          working_directory: frontend
          needs: [checkout]
          when:
            pull_request:
              paths: ["frontend/**"]
            push: {}
        - name: e2e
          kind: run
          image: mcr.microsoft.com/playwright:v1.56.0-noble
          run: corepack enable && pnpm install --frozen-lockfile && pnpm run e2e
          working_directory: frontend
          needs: [checkout, frontend-test]
          skippable_needs: [frontend-test]
        - name: migrations-check
          kind: run
          image: golang:1.26
          run: go run ./cmd/migrate --dry-run
          needs: [checkout]
          if: "event.kind == 'push' || event.changed_files.exists(f, f.startsWith('db/migrations/'))"
      ```
    </Accordion>
  </Tab>
</Tabs>

## Build once and promote on merge

Publish the image the pull request run already built, scanned and tested, instead of building the merged commit again.

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

A promoted push runs `publish` alone: it re-tags the gate-approved digest as the push's own `sha-<7>` tag, and push-to-deploy rolls it out. The other stages show as skipped with reason `promoted`, naming the run that passed them. This is the "15 seconds from merge to published" example.

A push is promoted when its commit has the same git tree as a pull request run that passed every stage `publish` needs, under the same approved pipeline authority, within the last 72 hours, from a branch in the repository itself. Anything else builds and tests in full, and the run records why. A stage that a pull-request-only filter skipped did not pass, so that merge builds in full too.

<Note>
  `promote_from` is a protected section: an administrator approves it with the default branch's definition, and a pull request cannot turn it on for itself. Pipelines Ankra generates for an application already declare it when their shape allows. A publish that meets a registry timeout or an outage status is retried for up to five minutes before the step fails.
</Note>

<Accordion title="Full pipeline.yaml">
  ```yaml theme={null}
  apiVersion: ankra.io/v1
  kind: Pipeline
  metadata:
    name: shop
  on:
    push:
      branches: [main]
    pull_request:
      branches: [main]
  defaults:
    network: egress-https
  stages:
    - name: checkout
      kind: checkout
    - name: test
      kind: run
      image: golang:1.26
      run: go test ./...
      needs: [checkout]
    - name: build
      kind: build
      needs: [test]
      build:
        dockerfile: Dockerfile
    - name: scan
      kind: scan
      needs: [build]
      scan:
        scanners: [semgrep, trivy]
    - name: gate
      kind: gate
      needs: [scan]
    - name: publish
      kind: publish
      needs: [gate]
      when:
        events: [push]
      publish:
        promote_from: pull_request
  ```
</Accordion>

## Test each pull request as it will merge

The merge train tests each pull request squashed onto the latest main, then merges it through GitHub's own merge. When that run passed every stage `publish` needs, `promote_from` publishes its image on the merge.

<CliVersion since="0.30.0" />

```bash theme={null}
ankra pipeline train enable --application my-app
ankra pipeline train add 412 --application my-app
ankra pipeline train list --application my-app
```

* A train run uses the pull request's own list of changed files, so the same filters apply. A train run with nothing left to run passes, and is never a source for promotion.
* `--max-cars 3` on `enable` tests three pull requests at once. One that fails leaves the train, and the ones behind it are tested again without it.
* Make the **Ankra merge train** check required on main, and every merge goes through the train.

The merge train runs on GitHub repositories. See [the merge train](/guides/ankra-pipelines#the-merge-train-test-every-pull-request-as-it-will-merge) for what happens when main moves or a car fails.

## Run slow suites nightly

Run a suite that is too slow for every pull request once a night, and cancel a branch's older run when a newer commit arrives.

```yaml theme={null}
on:
  schedule:
    - cron: "0 3 * * *"
      branch: main
      stages: [checkout, e2e-all-browsers]
concurrency:
  group: "${{ ankra.repository.name }}-${{ ankra.ref }}"
  cancel_in_progress: true
```

The schedule's `stages` list restricts the nightly run to those stages, so name every stage they need, `checkout` included. `when.events: [schedule]` on the suite keeps it out of push and pull request runs. A cancelled older run shows as **Superseded**, with a neutral commit status and no failure notification.

<Accordion title="Full pipeline.yaml">
  ```yaml theme={null}
  apiVersion: ankra.io/v1
  kind: Pipeline
  metadata:
    name: shop
  on:
    push:
      branches: [main]
    pull_request:
      branches: [main]
    schedule:
      - cron: "0 3 * * *"
        branch: main
        stages: [checkout, e2e-all-browsers]
  concurrency:
    group: "${{ ankra.repository.name }}-${{ ankra.ref }}"
    cancel_in_progress: true
  defaults:
    network: egress-https
  stages:
    - name: checkout
      kind: checkout
    - name: test
      kind: run
      image: node:24
      run: corepack enable && pnpm install --frozen-lockfile && pnpm run test
      needs: [checkout]
    - name: e2e-all-browsers
      kind: run
      image: mcr.microsoft.com/playwright:v1.56.0-noble
      run: corepack enable && pnpm install --frozen-lockfile && pnpm run e2e --project=all
      needs: [checkout]
      when:
        events: [schedule]
  ```
</Accordion>

## Write each path list once

A monorepo filters many stages on the same directories, and most of those lists also name the pipeline file. `path_sets:` names each list once. Any path list includes a set with a `{set: <name>}` entry, and a set can include another set.

```yaml theme={null}
path_sets:
  pipeline: [".ankra/**"]
  backend: ["backend/**", {set: pipeline}]
  frontend: ["frontend/**", "packages/ui/**", {set: pipeline}]
```

```yaml theme={null}
- name: backend-test
  kind: run
  image: golang:1.26
  run: go test ./...
  working_directory: backend
  needs: [checkout]
  when:
    pull_request:
      paths: [{set: backend}]
    push: {}
```

Ankra replaces each reference with the set's patterns where it stands, when it reads the file, so the plan is exactly the plan of the lists written out by hand. Changing `pipeline` changes every list that includes it. `ankra pipeline validate` refuses a reference to a set that does not exist and sets that include each other, warns about a set nothing uses, and lists every set with its patterns and the stages that use it. See [Path sets](/guides/pipeline-reference#path-sets).

<Accordion title="Full pipeline.yaml">
  ```yaml theme={null}
  apiVersion: ankra.io/v1
  kind: Pipeline
  metadata:
    name: shop
  path_sets:
    pipeline: [".ankra/**"]
    backend: ["backend/**", {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: backend-test
      kind: run
      image: golang:1.26
      run: go test ./...
      working_directory: backend
      needs: [checkout]
      when:
        pull_request:
          paths: [{set: backend}]
        push: {}
    - name: frontend-test
      kind: run
      image: node:24
      run: corepack enable && pnpm install --frozen-lockfile && pnpm run test
      working_directory: frontend
      needs: [checkout]
      when:
        pull_request:
          paths: [{set: frontend}]
        push: {}
    - name: frontend-lint
      kind: run
      image: node:24
      run: corepack enable && pnpm install --frozen-lockfile && pnpm run lint
      working_directory: frontend
      needs: [checkout]
      when:
        pull_request:
          paths: [{set: frontend}]
        push: {}
  ```
</Accordion>

## Run everything when you need to

Skipping is the default only where you asked for it, and you can always set it aside.

* **One run in full.** `ankra pipeline run --full` or `ankra pipeline rerun <run> --full` runs every stage, whatever the path filters say.

* **A pull request in full.** Add the `ankra:full-run` label on GitHub. A full run starts at once, every later push to the pull request runs in full while the label stays, and so does its merge train car.

* **Main in full.** Add a `full_run` block, or have an organisation administrator turn on `ci_full_run_on_default_branch`:

  ```yaml theme={null}
  full_run:
    events: [push]
    branches: [main]
  ```

* **A stage that is never skipped.** List it in the organisation's `ci_always_run_stages`. It runs with every stage it needs, whatever its path filters or the trigger's say, and a pull request cannot change that, because the list is not in the repository.

* **Nothing at all.** A pushed commit with `[skip ci]` in its message records a skipped run. By default that works on every branch but the default branch; `ci_skip_markers` changes where.

Each full run says why on the run page, for example "Path filters were set aside: a full run was requested by user:...". See [Setting path filters aside](/guides/pipeline-reference#setting-path-filters-aside) for every source and setting.

## Why a skip never hides a failure

<Tip>
  A path filter only skips a stage when Ankra has the complete list of files the change touched. When it cannot be sure, the stage runs, and the run's `paths_unverified` says why.
</Tip>

| Situation | What Ankra does |
| - | - |
| No file list, more than 300 files, a push that created the branch, or a push that rewrote history | Nothing is skipped for its paths: `provider_error`, `unsupported_provider`, `truncated`, `branch_created` or `forced_push` |
| An earlier push to the branch was cancelled or failed | A push's files are counted from the last push run on the branch that succeeded within 30 days, so they are still in the list |
| The change edits `.ankra/pipeline.yaml` | Every path filter is set aside (`plan_definition_changed`) |
| A pull request is retargeted to another base | It is planned again against the new base, and the new check replaces the old one |
| An `if:` can only be decided when its step starts | It reads the run's stored list, and runs when that list is incomplete and the answer depends on it |
| A stage declares `affected:` | It is skipped only when the run's checkout read the graph and answered that no changed file reaches it. An analysis that failed, timed out or found nothing to match runs the stage |
| A push asks to promote | Only an identical tree, the same approved authority, the gate's own digest, within 72 hours, never from a fork |

Pushes and pull requests are filtered the same way on GitHub, GitLab and Bitbucket Cloud, up to 300 changed files.

## Preview before you commit

<Steps>
  <Step title="Plan the pipeline for a change">
    `ankra pipeline validate` plans the pipeline for a push and a pull request without running anything. Each `--changed-file` makes the path filters decide for real.

    ```bash theme={null}
    ankra pipeline validate --application my-app \
      --spec-file .ankra/pipeline.yaml \
      --changed-file web/app.ts
    ```
  </Step>

  <Step title="Read the plan">
    The output ends with every stage against every event. For the **Every event** pipeline above:

    ```text theme={null}
    ╭──────────┬───────────────────┬────────────────────╮
    │ STAGE    │ PUSH              │ PULL_REQUEST       │
    ├──────────┼───────────────────┼────────────────────┤
    │ checkout │ run               │ run                │
    │ test-web │ run               │ run                │
    │ build    │ run               │ run                │
    │ scan     │ run               │ run                │
    │ gate     │ run               │ run                │
    │ publish  │ run               │ skip: event_filter │
    │ test-api │ skip: path_filter │ skip: path_filter  │
    ╰──────────┴───────────────────┴────────────────────╯
    ```
  </Step>

  <Step title="Try a few typical changes">
    Run it for a documentation edit, one component and two components. The command also reports every refusal and warning above, and exits non-zero only when the pipeline cannot be planned. For stages with `affected:`, run it inside your checkout: it reads the same dependency graph from your working tree and marks each one `run (graph: affected)` or `skip: not affected (graph)`.
  </Step>
</Steps>

## See what you saved

Ankra records the CI time each run did not spend because of a path filter or a promoted publish, and shows it in three places.

* The run page says what the run skipped, for example "Skipped 3 stages, saved about 14 min".
* The Pipelines page shows the total for the last 30 days.
* `ankra pipeline get <run-id>` prints the same figure under the run's steps, with one line per saving.

```text theme={null}
Time saved: about 14 min of CI time (3 stages skipped, 1 unknown)
  build: left out by a path filter, about 12 min (median of 20 successful runs of this stage)
  e2e: skipped because build was left out by a path filter, unknown (no history to estimate from)
  test: not run again, the publish was promoted, about 2 min (its time in run <run-id>)
```

Each figure is an estimate from the stage's own recent successful runs in the same repository. A stage that has no successful runs yet shows as unknown and is not counted. A stage skipped by a branch filter or a condition is not counted either, because it would not have run anyway. The figure is runner time, so two stages that would have run side by side count twice. Only runs planned after this was released are counted. The [pipeline.yaml reference](/guides/pipeline-reference#time-saved) describes the method and the API routes.

## Next steps

<CardGroup cols={2}>
  <Card title="Ankra Pipelines" icon="route" href="/guides/ankra-pipelines">
    How a run happens, the merge train and affected-only stages.
  </Card>

  <Card title="pipeline.yaml reference" icon="book" href="/guides/pipeline-reference">
    Every key, event-scoped filters, expressions and validator diagnostics.
  </Card>

  <Card title="Promote a tested image" icon="arrow-up-right-dots" href="/guides/pipeline-reference#promote-a-tested-image-instead-of-rebuilding">
    Every condition a push must meet to publish a pull request run's image.
  </Card>

  <Card title="Get started with Ankra CI" icon="play" href="/get-started/ankra-ci">
    Connect a repository and get its first commit through a run.
  </Card>
</CardGroup>


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