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.
| 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 | 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]:
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:
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:
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 testWhat 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
mainof 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:
"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-ignorefilter 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 initsets 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.