Guides / Control bot-triggered CI

CI runs too often

Control ChatOps and bot-triggered CI

By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026

A dependency bot opens 20 PRs overnight. Each one triggers your full CI pipeline, including build, lint, test, and deploy preview. By morning you've burned 300 minutes on version bumps that could have been validated with lint and unit tests alone. Meanwhile, a ChatOps slash command fires issue_comment workflows on every comment in a busy PR thread. One actor, one automation loop, and your CI bill doubles. This is fixable without turning bots off.

Symptoms

How to tell if bots are inflating your CI spend

Open your Actions tab and filter by actor. If you see one name dominating the run list, you've found the problem.

  • One actor dominates run count. A single triggered_by (like dependabot[bot] or renovate[bot]) accounts for 30–60% of all workflow runs. These runs are legitimate but individually low-value, since most are minor version bumps that rarely fail.

  • Burst of PRs triggers burst of CI. Dependency update bots like Renovate or Dependabot can open 10–30 PRs in a single batch. Each PR independently triggers the full pipeline. If your CI takes 15 minutes per run, that's 150–450 minutes consumed overnight from automation alone.

  • Comment-triggered workflows fire too often. ChatOps-style workflows using issue_comment triggers run on every comment in the repository, not just slash commands. A busy PR with 20 review comments triggers 20 workflow runs, and 19 of them immediately exit after checking the comment body.

  • Label/automerge loops. A bot labels a PR, which triggers a workflow, which updates the PR, which triggers another workflow. Dependabot's simultaneous PR-open-and-label behavior is a known source of this: the label event and the PR event both queue runs, and cancel-in-progress can pick the wrong one, leaving status checks stuck.

Metrics

What bot-triggered CI actually costs

Consider a repository with Dependabot configured for 5 package ecosystems, each checking weekly. Renovate or Dependabot opens roughly 20 PRs per batch. Each PR triggers a 15-minute CI pipeline on Linux runners.

Before optimization

Bot PRs/week 20
Minutes/run 15
Monthly minutes 1,200
Monthly cost $7.20/mo

At $0.006/min (Linux 2-core) · full pipeline per bot PR

After optimization (lint + unit only)

Bot PRs/week 20
Minutes/run 4
Monthly minutes 320
Monthly cost $1.92/mo

Save $5.28/mo · 73% reduction · per repo

That's one repo on Linux. Scale to 10 repositories and you're saving $52.80/mo. On macOS runners at $0.062/min, the same 20-PR scenario costs $74.40/mo before optimization and $19.84/mo after, which is a saving of $54.56/mo per repo. And that's before counting the ChatOps comment-triggered runs, which can easily double the total.


Fix 1

Skip heavy jobs for bot actors

The github.actor context variable contains the username of whoever triggered the workflow. For bots, this follows the pattern name[bot], such as dependabot[bot] or renovate[bot]. You can use this to conditionally skip expensive jobs like E2E tests, integration suites, or deploy previews when a bot opens the PR.

The key insight: dependency version bumps rarely need a full E2E suite. Lint, typecheck, and unit tests catch the vast majority of breakage from dependency updates. Reserve the full pipeline for human-authored code changes. For more on controlling when E2E suites run, see our guide on reducing E2E test frequency.

.github/workflows/ci.yml
name: CI

on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  e2e:
    runs-on: ubuntu-latest
    if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }}
    steps:
      - uses: actions/checkout@v4
      - run: npm run e2e

  deploy-preview:
    runs-on: ubuntu-latest
    if: ${{ github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' }}
    needs: [lint, unit-test, e2e]
    steps:
      - uses: actions/checkout@v4
      - run: npm run deploy:preview

One caveat: github.actor reflects the last user who interacted with the PR, not necessarily the author. If a human pushes a commit to a Dependabot PR, the actor changes to that human, and the E2E suite runs. This is usually the behavior you want, because once a human is involved, you should run the full pipeline.

If you need to check the original PR author instead, use github.event.pull_request.user.login, which always reflects who opened the PR regardless of subsequent activity.

Fix 2

Use concurrency groups to throttle bot runs

When a dependency bot opens 20 PRs at once, each one independently triggers CI. If your runners are shared, those 20 runs compete for capacity, slowing down human PRs too. Concurrency groups let you serialize bot runs so only one bot PR is validated at a time, while human PRs run in parallel as usual.

The trick is to use github.actor in the concurrency group key. Since all Dependabot PRs share the same actor (dependabot[bot]), they all land in one concurrency group. Human PRs each get their own group keyed by PR number, so they run concurrently.

.github/workflows/ci.yml
name: CI

on:
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.user.login == 'dependabot[bot]' && 'dependabot' || github.event.pull_request.number }}
  cancel-in-progress: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}

This configuration does two things: it groups all Dependabot PRs into a single concurrency slot (so only one runs at a time, with new ones cancelling the previous), and it keeps human PRs in their own per-PR groups (no cancellation between different PRs). The net effect: 20 bot PRs consume the minutes of 1–2 runs instead of 20. For more on using concurrency groups to prevent canceled runs from wasting minutes, see the dedicated guide.

One caveat: Dependabot can simultaneously open a PR and add a label, triggering two workflow runs at once. With cancel-in-progress: true, the concurrency system picks one at random to cancel. If it cancels the one your required status checks are watching, the PR gets stuck. If you use Dependabot labels for automerge workflows, consider separating the automerge logic into its own workflow that doesn't use cancel-in-progress.

Fix 3

Create a lightweight bot-only workflow

Instead of adding if conditions to every job in your main CI workflow, you can split bot validation into its own dedicated workflow. The main CI workflow skips bot actors entirely, and the bot workflow runs only lint and unit tests. This keeps your main workflow clean and gives you a separate required status check for bot PRs.

ci.yml - human PRs only
name: CI

on:
  pull_request:

jobs:
  gate:
    if: ${{ github.actor != 'dependabot[bot]'
        && github.actor != 'renovate[bot]' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Human PR"

  lint:
    needs: gate
    # ... full lint, test, e2e, deploy
ci-bot.yml - bots only
name: CI (Bot)

on:
  pull_request:

jobs:
  gate:
    if: ${{ github.actor == 'dependabot[bot]'
        || github.actor == 'renovate[bot]' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Bot PR"

  lint:
    needs: gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  unit-test:
    needs: gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

If you use branch protection required checks, you'll need to configure separate required status checks for human and bot PRs. One approach is to have both workflows report a shared check name using a final job that always runs (even when earlier jobs are skipped). Alternatively, use a status check like actions/github-script to report a unified commit status.

Fix 4

Optimize ChatOps slash command workflows

The issue_comment trigger fires on every comment in a repository, not just slash commands. A busy repo with 100 comments per day triggers 100 workflow runs, even if only 2 of those comments contain a /deploy or /test command. Each run consumes at least 1 billed minute for the runner setup alone.

The most efficient pattern is to use repository_dispatch instead of parsing comments directly. The slash-command-dispatch action by Peter Evans is the standard solution: it runs a lightweight issue_comment handler that checks for slash commands and, only when one is found, dispatches a repository_dispatch event to trigger the actual work.

Runs on every comment
name: ChatOps
on:
  issue_comment:
    types: [created]

jobs:
  deploy:
    if: contains(github.event.comment.body,
        '/deploy')
    runs-on: ubuntu-latest
    steps:
      # Heavy deploy logic here
      # Runs even if job is skipped -
      # runner still boots + bills
Dispatches only on slash command
# File 1: lightweight dispatcher
name: Slash Commands
on:
  issue_comment:
    types: [created]
jobs:
  dispatch:
    runs-on: ubuntu-latest
    steps:
      - uses: peter-evans/slash-command-dispatch@v4
        with:
          commands: deploy,test
          token: ${{ secrets.PAT }}

# File 2: actual work (only triggered on match)
name: Deploy
on:
  repository_dispatch:
    types: [deploy-command]
jobs:
  deploy:
    # Only runs when /deploy is typed

The dispatcher workflow still runs on every comment, but it's a single lightweight step that exits in seconds. The heavy deploy/test logic only triggers when a matching slash command is found. This pattern eliminates runner boot overhead for non-command comments.

One caveat: the slash-command-dispatch action requires a personal access token (PAT) or GitHub App token with repo scope, because the default GITHUB_TOKEN cannot create repository_dispatch events.


Reference

Common bot actor names for filtering

When writing if conditions to detect bot actors, you need the exact github.actor string. These are the most common bots that trigger CI in GitHub Actions:

Bot github.actor value Typical trigger
Dependabot dependabot[bot] Dependency update PRs
Renovate renovate[bot] Dependency update PRs
GitHub Actions github-actions[bot] Automated commits, releases
Snyk snyk-bot Security fix PRs
Mergify mergify[bot] Auto-rebase, queue merges

For custom GitHub Apps, the actor format is app-name[bot]. You can find the exact name by checking the github.actor value in a workflow run log, or by querying the GitHub API for the app's bot user.

If you want to catch all bots generically instead of listing them individually, you can use endsWith(github.actor, '[bot]') as a broader filter. This catches any GitHub App bot, though it won't catch custom CI bots that use regular user accounts.

# Generic bot detection
jobs:
  e2e:
    if: ${{ !endsWith(github.actor, '[bot]') }}
    # Skips for any GitHub App bot actor

Reference

How bot runs are billed

There is an important distinction between what bots do (create PRs, update lockfiles) and what your CI workflows do in response (run tests, build artifacts). Dependabot's own update jobs, meaning the work that generates the PR and commits the lockfile change, do not count against your Actions minutes, even on private repos. This has been free since Dependabot on Actions became generally available.

However, any CI workflows that trigger from those bot PRs (your test suite, lint checks, deploy previews) consume your normal Actions minutes on private repos at the standard runner rates: $0.006/min on Linux, $0.010/min on Windows, $0.062/min on macOS. On public repos, all Actions usage is free regardless of who triggers it.

Related guides

Guides / Control bot-triggered CI

See which actors are burning your CI minutes

CostOps breaks down CI spend by actor, so you can see exactly how much bots cost versus human developers. Spot the automation loops before they hit your bill.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.