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
At $0.006/min (Linux 2-core) · full pipeline per bot PR
After optimization (lint + unit only)
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.
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.
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.
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
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.
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
# 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
Stop Duplicate CI Trigger Runs
Prevent push and pull_request events from running the same workflow twice.
Canceled Runs Still Waste Minutes
Use concurrency groups to auto-cancel superseded runs before they finish.
E2E Tests Running Too Often
Run expensive end-to-end suites only when they matter, not on every push.
Scheduled Jobs That Don't Earn Their Keep
Audit cron-triggered workflows that run on a schedule but rarely catch issues.