# Skip CI when the code hasn't changed

You can skip CI in three ways: say so in the commit message, filter by the paths a push changed, or compare the code with what already passed. The first two decide from the push. Only the third knows whether these exact files were tested.

| Method | Decides from | What it skips | The catch |
|---|---|---|---|
| `[skip ci]` in the commit message | A person's judgment | The whole workflow | A required check stays pending |
| `paths` filter on the workflow | The paths the push changed | The whole workflow | A required check stays pending, and a shared file outside the filter goes untested |
| `if:` on a job | Whatever you compute | That job | You write and maintain the condition |
| [`hushrun run`](https://hushrun.com/docs/run.md) | The files, the check and its command | That check | The job still starts and installs |

## Skip with the commit message

GitHub Actions skips the workflows a commit would start when its message contains `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]`:

```text
docs: fix a typo in the README [skip ci]
```

It applies to `push` and `pull_request` events, not to `pull_request_target`, and [GitHub's docs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs) also describe a `skip-checks: true` trailer. Other CI systems have their own words:

| CI | What skips it |
|---|---|
| GitLab CI | `[ci skip]` or `[skip ci]`, in any capitalization, or the `ci.skip` push option, which doesn't skip merge request pipelines |
| [Azure Pipelines](https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/azure-repos-git?view=azure-devops) | `[skip ci]`, `[ci skip]`, `skip-checks: true`, `[skip azp]`, `[azp skip]` or `***NO_CI***` |
| [Bitbucket Pipelines](https://support.atlassian.com/bitbucket-cloud/kb/how-to-skip-triggering-an-automatic-pipeline-build-using-skip-ci-label/) | `[skip ci]` or `[ci skip]`, in the first 200 characters of the message |
| [CircleCI](https://circleci.com/docs/guides/orchestrate/skip-build/) | `[ci skip]` or `[skip ci]` in the latest commit, which skips the whole push |

Someone has to remember it, and be right. A coding agent has to judge this on every push, and a commit marked by mistake reaches `main` untested.

## Skip by path

A path filter starts a workflow only when the push changed matching files:

```yaml title=".github/workflows/ci.yml"
on:
  pull_request:
    paths-ignore:
      - "**.md"
      - "docs/**"
```

This suits changes that can't affect a check, such as documentation. Be careful with a filter that lists what to include, such as `src/**`: a change to a lockfile or a shared package outside it passes with nothing tested. GitLab CI's equivalent is [`rules:changes`](https://docs.gitlab.com/ci/yaml/), which always evaluates to true on new branches unless you set `compare_to`.

### Required checks stay pending

When a filter or a commit message skips a workflow, its checks never report. If they're required, [GitHub leaves them pending](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks), showing "Waiting for status to be reported", and the pull request can't merge.

A job skipped by `if:` behaves differently: [it reports success](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-jobs-with-conditions), so it doesn't block. To filter a required check by path, start the workflow every time and decide in the job. [Fix "Waiting for status to be reported"](https://hushrun.com/docs/guides/required-check-waiting-for-status.md) covers the other causes:

```yaml title=".github/workflows/ci.yml"
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: actions/checkout@v7
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            code:
              - "src/**"
              - "package-lock.json"
  test:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci
      - run: npm test
```

## What path filters can't see

A path filter asks what changed in this push. It can't ask whether this code already passed, so it reruns the check in cases where nothing new needs testing:

- a commit that's reverted, which brings back files that passed;
- a rebase or a squash that leaves the files as they were;
- a merge to `main` of exactly what the pull request tested;
- the same change reaching staging and then production.

## Skip by comparing the code

`hushrun run` in front of a command runs it only when no pass is recorded for the same files, check name, command and directory. Wrap a script once:

```json title="package.json"
"scripts": {
  "test": "hushrun run -- vitest run"
}
```

The first run on this code runs the check. The next one, on any machine that shares results, skips it:

```console
$ hushrun run e2e -- playwright test --project site
hushrun: e2e running: no pass yet for this exact code
…
hushrun: e2e passed in 16s; it won't run again on this code
$ hushrun run e2e -- playwright test --project site
hushrun: e2e skipped: this exact code passed 1 min ago (saves 16s)
```

A skip exits 0, so a required check still reports success and nothing stays pending. Each of the four cases above skips, because the files are the same. A change anywhere in the repository runs the check again unless you narrow it with [scopes](https://hushrun.com/docs/run.md#monorepos).

The job itself still starts, checks out the code and installs dependencies. hushrun saves the check's time, not the job's.

## Which one to use

- **Documentation-only changes:** a `paths-ignore` filter on checks that aren't required.
- **Required checks with expensive jobs:** an `if:` on the job, so it reports success.
- **The same code reaching CI again:** `hushrun run`. [`npx hushrun init`](https://hushrun.com/docs/quickstart.md) sets it up in about three minutes.

They combine: a path filter can drop what never needs a run, and `hushrun run` handles repeats of the rest.

## Related

- [Run GitHub Actions only when files change](https://hushrun.com/docs/guides/github-actions-run-when-files-change.md)
- [Run GitLab CI jobs only when files change](https://hushrun.com/docs/guides/gitlab-ci-rules-changes.md)
- [Why CI runs the same tests twice](https://hushrun.com/docs/guides/duplicate-ci-runs.md)
- [How skipping works](https://hushrun.com/docs/run.md)
- [In CI](https://hushrun.com/docs/ci.md)
