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

# Dynamic CI

> Skip the CI jobs your diff doesn't need, using your own repository's history instead of hand-written path globs.

<Warning>
  Dynamic CI is in **private beta**. Recommendation quality is still improving,
  the signals behind a verdict still change between releases, and the action's
  inputs and outputs are not yet stable. Pin a specific tag if you need
  stability, and read the release notes before upgrading.
</Warning>

## What it is

Most CI runs are wasted. A docs-only change does not need the integration suite; a
frontend typo does not need the backend tests. The usual fix is a path filter —
hand-written globs that are coarse, go stale, and encode a guess about which files
matter to which job.

Dynamic CI answers the same question from evidence instead. Trunk already records every
`(file change set → job outcome)` pair in your repository, so it can tell you which jobs
this diff has historically broken and which it never touches. The
[`trunk-io/dynamic-ci-filter`](https://github.com/trunk-io/dynamic-ci-filter) action asks
for a verdict per job and emits it as a job output you gate on — a near drop-in
replacement for [`dorny/paths-filter`](https://github.com/dorny/paths-filter).

## How a verdict is reached

Each verdict combines independent signals, and every signal's contribution is printed in
the job logs and the job summary, so a skip is always auditable.

| Signal                   | What it looks at                                                 |
| ------------------------ | ---------------------------------------------------------------- |
| `estimated-cost`         | What the job costs to run.                                       |
| `previous-result-on-pr`  | Whether this job already ran on this PR, and how it did.         |
| `historical-pass-rate`   | How often this job has failed historically.                      |
| `diff-driven-volatility` | How often changes to these files have broken this job.           |
| `force-override`         | An explicit user override forcing the job to run.                |
| `merge-failure`          | Whether this job failed on the PR's most recent merge-queue run. |
| `mid-pr-stack`           | Whether another open PR is stacked on top of this one.           |
| `required-check`         | Whether the PR's base branch requires this job to merge.         |

Signals are per-organization. Nothing is pooled across customers — your recommendations
are derived only from your own CI history.

Pass a comma-separated list to `ignore-signals` to drop any of them from the tally.

## Set it up

<Steps>
  <Step title="Get your organization API token">
    In the Trunk app, go to **Settings → Manage Organization → Organization API
    Token**, and store it as a repository secret (for example `TRUNK_API_TOKEN`).
  </Step>

  <Step title="Add the filter job">
    One upstream job asks for verdicts for the whole workflow. Per-job outputs are set
    at runtime, so re-export each one you intend to gate on by name.

    ```yaml theme={null}
    jobs:
      dynamic-ci-filter:
        name: Dynamic CI Filter
        runs-on: ubuntu-latest
        timeout-minutes: 5
        if: github.event_name == 'pull_request'
        outputs:
          unit-tests: ${{ steps.ci-filter.outputs.unit-tests }}
          integration-tests: ${{ steps.ci-filter.outputs.integration-tests }}
        steps:
          - name: Run Dynamic CI Filter
            id: ci-filter
            uses: trunk-io/dynamic-ci-filter@v1
            with:
              token: ${{ secrets.TRUNK_API_TOKEN }}
    ```
  </Step>

  <Step title="Gate your jobs on the output">
    ```yaml theme={null}
      unit-tests:
        name: Unit Tests
        runs-on: ubuntu-latest
        needs: [dynamic-ci-filter]
        if: >-
          !cancelled() &&
          needs.dynamic-ci-filter.outputs.unit-tests != 'false'
        steps:
          - run: make test
    ```
  </Step>
</Steps>

Jobs are addressed by their **key** — what the job is written as under `jobs:`, and what
`github.job` reports — not the `name:` it displays under. A display name changes with a
job's matrix values; the key does not.

This is *pre-job* mode, and it captures the most savings because a skipped job never boots
a runner. There is also a *pre-step* mode, where a job asks only about itself using the
`job-keys` input; it is simpler to adopt but the runner has already started by the time
the verdict arrives.

## Always write `!= 'false'`

<Warning>
  Gate on `!= 'false'`, never on `== 'true'`.
</Warning>

A job with no verdict — a service outage, a job Trunk has not seen before, a job whose key
it has not resolved yet — emits no output at all. `!= 'false'` correctly runs that job.
`== 'true'` would silently skip your entire test suite the first time anything went wrong.

That convention is what makes the action's fail-open behavior work. A transport error, a
non-2xx response, a malformed response, or a request past the latency budget (30s by
default) all resolve to "run", the step itself never fails, and every fail-open is logged
as a warning annotation and written to the job summary — so a real skip is always
distinguishable from a degraded one.

## Merge queues

A merge queue validates the exact commit about to land, so a skip there could merge
untested code. Trunk therefore **never skips a job on a Trunk Merge Queue branch**: a
request on a `trunk-merge/` branch short-circuits to run-everything before any
recommendation work happens.

You do not need to gate the queue yourself, and in particular you should not add
`github.event_name != 'pull_request'` — with draft merge-queue pull requests enabled,
queue batches arrive as `pull_request` events on `trunk-merge/*` branches, so the event
name cannot tell them apart from real PRs.

Other merge queues are not covered. GitHub's native merge queue validates on
`gh-readonly-queue/*` branches via `merge_group` events, which are treated like any other
request, so gate those yourself:

```yaml theme={null}
if: >-
  !cancelled() &&
  (github.event_name == 'merge_group' ||
  needs.dynamic-ci-filter.outputs.unit-tests != 'false')
```

## Feedback

Dynamic CI is being tuned against real repositories during the beta, and the fastest way
to change it is to tell us what it got wrong. Reach out on
[Slack](https://slack.trunk.io).
