Skip to content

2.03 Cancelling and Skipping Workflow Runs

Overview

Not every triggered run should finish, and not every matching event deserves a run at all. Cancelling stops work already in flight; skipping prevents it from starting.

Abstract

By default a job fails as soon as one of its steps fails, and a failing job cancels the rest of the workflow — behavior you can override later with continue-on-error, if: always(), and fail-fast. Runs can also be cancelled by hand from the run page or the CLI. Skipping works differently: all matching events normally start a workflow, but push and pull_request events honor a commit-message annotation such as [skip ci], which suppresses the run entirely.

Why It Matters in Production

Cancelling is about not paying for work you already know is worthless. A run that will fail in twelve minutes because of a typo you spotted thirty seconds after pushing is twelve minutes of runner time and twelve minutes of waiting for everyone behind it in the queue.

Skipping is about not starting worthless work at all. A comment, a changelog line, a README fix — none of it changes behavior, and running the full test-and-deploy pipeline for it costs minutes and, on paid plans, money.

The caution with both: a cancelled or skipped run produces no status check. If that check is required for merging, the pull request stops dead.

Key Concepts

Cancelling

Behavior Default
A step fails The job fails
A job fails Remaining and dependent jobs are cancelled
Manual cancellation Available from the run page's Cancel workflow button
Overrides continue-on-error, if: always(), fail-fast: false, timeout-minutes

Skipping

Behavior Detail
Default Every matching event starts a workflow
Exception push and pull_request events only
Mechanism A keyword in the commit message
For pull requests The latest commit on the head branch decides
Keywords [skip ci], [ci skip], [no ci], [skip actions], [actions skip]

Where the keyword goes

The annotation must appear in the commit message's first line or in the two-line block after a blank line — not buried in the middle of a long body. Square brackets are part of the syntax.

Skipping does not apply to every event

Only push and pull_request honor skip keywords. A workflow_dispatch, schedule, or repository_dispatch run ignores the commit message entirely.

Common Use Cases

  • Cancel a run you know will fail, instead of waiting for it to prove it
  • Cancel a hung or runaway job consuming runner minutes
  • [skip ci] for comment-only, README, or changelog commits
  • [skip ci] on automated commits a bot pushes back into the repository, to avoid trigger loops
  • concurrency with cancel-in-progress so a new push cancels the superseded run automatically
  • timeout-minutes to cancel anything that exceeds its expected duration

Example Configuration or Commands

Skipping a run through the commit message:

# This push matches the workflow's triggers but starts no run
git commit -m "added comments [skip ci]"
git push origin main

# Equivalent keywords
git commit -m "docs: fix typo [ci skip]"
git commit -m "chore: bump version [no ci]"
git commit -m "comment only [skip actions]"
git commit -m "formatting [actions skip]"

Cancelling from the CLI instead of the run page:

# Find the in-flight run
gh run list --status in_progress

# Cancel it
gh run cancel <run-id>

# Watch what happens
gh run watch
# A newer push on the same ref cancels the older in-flight run
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10      # cancels the job past this point
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Run tests
        timeout-minutes: 5   # per-step limit
        run: npm test
steps:
  - name: Optional lint
    continue-on-error: true    # step may fail without failing the job
    run: npm run lint
  - name: Tests
    run: npm test
jobs:
  notify:
    needs: [test, deploy]
    if: always()               # default would skip this after a failure
    runs-on: ubuntu-latest
    steps:
      - run: echo "Result: ${{ needs.test.result }}"
# Structural alternative — no annotation needed per commit
on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

Preventing a bot commit loop

A workflow that commits back to the repository — a version bump, a generated changelog — will retrigger itself on push. Annotating the bot's commit breaks the cycle:

- name: Commit generated files
  run: |
    git config user.name "github-actions[bot]"
    git config user.email "github-actions[bot]@users.noreply.github.com"
    git add dist/
    git commit -m "chore: rebuild assets [skip ci]"
    git push
Commits made with the default GITHUB_TOKEN don't trigger workflows anyway, but the annotation makes the intent explicit and still protects you when a PAT is used instead.

Best Practices

Best Practices

  • Cancel early. The moment you know a run is pointless, stop it — the queue behind it is shared with the rest of the team.
  • Set concurrency with cancel-in-progress: true on CI workflows so rapid pushes don't pile up; leave it off on deploy workflows where a half-finished deployment is worse than a redundant one.
  • Put timeout-minutes on every job. The platform default is generous, and a hung job silently consumes the allowance.
  • Prefer paths-ignore over per-commit [skip ci] for anything predictable, like docs directories — it works without discipline from contributors.
  • Reserve [skip ci] for one-off judgment calls, and say in the commit message why.
  • Use continue-on-error for genuinely optional steps rather than letting a flaky linter fail the build.
  • Add if: always() to notification and cleanup jobs so a failure still reports.

Security Best Practices

Security

  • [skip ci] is a bypass available to anyone who can push. On a branch whose pipeline runs security scanning, a contributor can skip the scan with a commit message. Make the scan a required status check, or run it on a schedule as well, so skipping cannot hide a change.
  • A skipped run reports no status at all — it is not a passing check. Branch protection must treat "no run" as "not approved", which is the default; do not configure around it.
  • Avoid [skip ci] on commits that touch .github/workflows/. Workflow edits are exactly what you want validated.
  • Cancelling a deploy mid-run can leave a partial deployment. Make deploy steps idempotent and prefer a rollback path over cancellation.
  • cancel-in-progress on a deploy workflow can abort a release halfway. Scope the concurrency group carefully, and consider cancel-in-progress: false for production environments.
  • Review who cancels production runs — cancellation is available to anyone with write access, and the run log records the actor.

Do and Don't

✅ Do ❌ Don't
Cancel runs you know will fail Let a doomed run occupy the queue
Use [skip ci] for comment/doc-only commits Use it to dodge failing tests
Prefer paths-ignore for predictable skips Rely on everyone remembering an annotation
Set timeout-minutes per job Assume a hung job will stop itself
Use cancel-in-progress on CI Apply it blindly to deploy workflows
Put the keyword in the commit's first line Bury it in the middle of a long message
Mark optional steps continue-on-error Let a flaky step fail the whole pipeline

Common Mistakes

Common Mistakes

  • Keyword in the wrong place. Mid-body text isn't recognized; use the first line or the first two lines after a blank line.
  • Missing brackets. skip ci without square brackets does nothing.
  • Expecting skip to work on other events. Scheduled and manually dispatched runs ignore it.
  • Skipping on a PR and expecting the merge to proceed. With no run there is no status check, so required checks stay pending.
  • Annotating the wrong commit in a PR. The latest commit on the head branch is what counts.
  • Thinking cancelled means passed. A cancelled run is neither success nor failure, and required checks treat it as not satisfied.
  • Assuming a failed job stops everything. Independent parallel jobs keep running; only dependent jobs are cancelled.
  • Using cancel-in-progress on deployments. The superseded run is killed mid-deploy.
  • Amending a message after pushing. The keyword is read at push time; rewriting history afterwards requires another push.

Breaks in production

[skip ci] on a commit that looked cosmetic but wasn't ships untested code to main. The pipeline shows no red run — it shows no run at all, which is easy to read as "nothing to worry about" during review.

Troubleshooting

# Confirm the exact commit message that was pushed
git log -1 --pretty=%B

# Did a run start for that commit?
gh run list --limit 5 --json headSha,event,status,conclusion

# Cancel the current in-flight run
gh run list --status in_progress --json databaseId --jq '.[0].databaseId' | xargs gh run cancel

# Which runs were cancelled recently, and by what?
gh run list --limit 20 --json conclusion,displayTitle --jq '.[] | select(.conclusion=="cancelled")'
Symptom Likely cause
No run after a push Skip keyword in the commit message, or a path/branch filter excluded it
Skip keyword ignored Wrong placement, missing brackets, or a non-push/pull_request event
PR cannot merge after skipping Required status check has no run to satisfy it
Run cancelled without anyone clicking cancel-in-progress superseded it, or timeout-minutes elapsed
Dependent job shows "cancelled" An upstream job failed — expected default behavior
Notification job never ran Missing if: always()
Workflow retriggers itself endlessly Bot commit pushes without [skip ci] and uses a PAT

Tip

Cancellation is not instantaneous. GitHub sends a stop signal and gives steps a grace period to shut down, so a long-running step may continue briefly. Cleanup steps marked if: always() still get a chance to run.

Quick Recap

  • A step failure fails the job; a job failure cancels the rest of the workflow by default.
  • Those defaults are adjustable with continue-on-error, if: always(), and fail-fast.
  • Runs can be cancelled manually from the run page or with gh run cancel.
  • All matching events start a workflow by default.
  • push and pull_request are the exceptions: a commit-message keyword skips the run.
  • Keywords: [skip ci], [ci skip], [no ci], [skip actions], [actions skip].
  • For a pull request, the latest commit's message decides.
  • A skipped or cancelled run produces no status check, which blocks required checks.
  • paths-ignore, concurrency, and timeout-minutes are the structural equivalents of skipping and cancelling.

Interview / Revision Notes

Quick revision

Q: What happens by default when a step fails? The job fails, and the rest of the workflow is cancelled.

Q: How do you let a step fail without failing the job? continue-on-error: true on the step.

Q: How do you cancel a run manually? The Cancel workflow button on the run page, or gh run cancel <run-id>.

Q: How do you skip a run that would otherwise be triggered? Put a keyword such as [skip ci] in the commit message.

Q: Which events honor skip keywords? push and pull_request only.

Q: Name the accepted keywords. [skip ci], [ci skip], [no ci], [skip actions], [actions skip].

Q: For a pull request, which commit message is checked? The latest commit on the head branch.

Q: How do you cancel superseded runs automatically? A concurrency group with cancel-in-progress: true.

Q: Why can skipping block a merge? No run means no status check, and a required check stays pending.

This topic can be expanded into sub-pages: continue-on-error and job-level failure handling, fail-fast in matrices, concurrency groups and deployment queues, timeouts and runner cost control, and required status checks with skipped runs.