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

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.

![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.](https://mintcdn.com/ankra/YXyjjLuuqrU4Bw7Z/images/use-cases/speed-up-ci.svg?fit=max\&auto=format\&n=YXyjjLuuqrU4Bw7Z\&q=85\&s=9b49b3ed5618c65d8e883d54e2572830)

## What it saves

Timings from Ankra's own pipelines.

> **15 seconds from merge to published**
>
> 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.

> **About 3 minutes instead of 19**
>
> A Go and Next.js repository. A pull request that does not touch the backend skips its 17 minutes of tests.

> **About 60 CPU minutes per merge**
>
> 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.

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

> **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]
> ```

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

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

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

> **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: {}
> ```

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

> **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]
> ```

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

> **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]
> ```

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

> **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/'))"
> ```

## 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. A stage the pull request run skipped as not affected (`affected:`) counts when the merge changes no file that run's dependency graph was not asked about, which is the case for a squash of an up-to-date pull request: same tree, so the same answer. A merge that changes more builds in full and reads the graph itself.

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

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

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

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

> **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]
> ```

## 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.md#path-sets).

> **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: {}
> ```

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

| 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

**1. 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
```

**2. 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  │
╰──────────┴───────────────────┴────────────────────╯
```

**3. 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)`.

## See what you saved

Ankra records the CI time each run did not spend because of a path filter, the dependency graph 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 of CI time."
* 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, or, for a promoted step, the time it took in the run it was promoted from. 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.md#time-saved) describes the method and the API routes.

## Next steps

> **[Ankra Pipelines](/guides/ankra-pipelines.md)**
>
> How a run happens, the merge train and affected-only stages.

> **[pipeline.yaml reference](/guides/pipeline-reference.md)**
>
> Every key, event-scoped filters, expressions and validator diagnostics.

> **[Promote a tested image](/guides/pipeline-reference.md#promote-a-tested-image-instead-of-rebuilding)**
>
> Every condition a push must meet to publish a pull request run's image.

> **[Get started with Ankra CI](/get-started/ankra-ci.md)**
>
> Connect a repository and get its first commit through a run.


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