Skip to content

Skip CI when the code hasn't changed

Updated

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.

MethodDecides fromWhat it skipsThe catch
[skip ci] in the commit messageA person's judgmentThe whole workflowA required check stays pending
paths filter on the workflowThe paths the push changedThe whole workflowA required check stays pending, and a shared file outside the filter goes untested
if: on a jobWhatever you computeThat jobYou write and maintain the condition
hushrun runThe files, the check and its commandThat checkThe 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]:

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 also describe a skip-checks: true trailer. GitLab CI takes [ci skip] or [skip ci], in any capitalization, or the ci.skip push option.

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:

.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, 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, showing "Waiting for status to be reported", and the pull request can't merge.

A job skipped by if: behaves differently: it reports success, so it doesn't block. To filter a required check by path, start the workflow every time and decide in the job:

.github/workflows/ci.yml
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        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@v4
      - 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:

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:

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

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

Markdown