Guides / Scheduled jobs that don't earn their keep

CI runs too often

Scheduled CI jobs that don’t earn their keep

By Keith Mazanec, Founder, CostOps ยท Updated February 8, 2026

Why are scheduled CI workflows wasteful?

Scheduled GitHub Actions workflows (cron jobs) run on a fixed timer regardless of code changes. A typical nightly build runs 30 times a month but catches a real issue only twice a year. Most teams can cut scheduled CI spend 50–80% by adding a change-detection gate that skips runs when nothing changed, reducing frequency from daily to weekly, or replacing cron triggers with event-driven paths: filters on push.

Your nightly build runs 30 times a month. It catches a real issue twice a year. The other 348 runs produce no signal, but you pay for every one. Cron-triggered workflows are the quietest line item on your CI bill because they run in the background, nobody reviews them, and the spend compounds silently.

Symptoms

How to tell if your scheduled workflows are wasting money

Open your repository’s Actions tab and filter by event:schedule. The patterns are easy to spot once you know what to look for:

  • High minutes from scheduled runs. Check what percentage of your total Actions minutes come from schedule-triggered workflows. If it’s over 20%, scheduled jobs are a meaningful cost driver, and unlike PR-triggered runs, they aren’t directly tied to developer productivity.

  • Near-zero failure rate. A nightly workflow that passes 99% of the time isn’t catching bugs. It’s confirming what you already know. If the failure rate is below 2%, the job is producing almost no actionable signal for its cost.

  • Runs on days with no code changes. Your cron job runs every night. On weekends, holidays, and slow weeks, nothing changed since the last run. The workflow re-validates the same SHA it already validated, burning minutes for identical results.

  • Failures nobody acts on. The nightly build failed last Tuesday. Nobody noticed until Thursday. If scheduled workflow failures don’t trigger an immediate response, the job isn’t serving as a safety net. It’s just generating noise.

Metrics

How much do scheduled workflows cost on GitHub Actions?

Three daily scheduled workflows across 4 private repos on standard Linux runners ($0.006/min) cost roughly $39/month in billed minutes. Reducing to 30% of those runs saves $28/month. Here’s the breakdown for a typical setup: a nightly build, a daily security scan, and a daily dependency check:

Before optimization

Scheduled runs/day 12
Avg minutes/run 18
Monthly minutes 6,480
Monthly cost $39/mo

At $0.006/min (Linux 2-core)

After optimization (70% fewer runs)

Scheduled runs/day 3.5
Avg minutes/run 18
Monthly minutes 1,890
Monthly cost $11/mo

Save $28/mo · $336/year · across 4 repos

That’s Linux. If any of those scheduled jobs run on macOS at $0.062/min, the before number jumps to $402/mo and the savings hit $285/mo. Free-tier teams on the GitHub Free plan get 2,000 minutes/month, and a single nightly build consuming 18 minutes eats 540 of those minutes, over a quarter of your entire allocation, before a single PR runs.


Fix 1

How to skip scheduled runs when nothing changed

The most direct fix: don’t run the build if no commits have landed since the last run. Add a check step at the top of your scheduled workflow that compares the current HEAD SHA against the last successful run. If the code hasn’t changed, exit early. You’ll burn 1 minute on the check step instead of 18 minutes on the full build.

.github/workflows/nightly.yml
name: Nightly Build

on:
  schedule:
    - cron: '30 2 * * 1-5'  # Weekdays at 02:30 UTC

jobs:
  check-changes:
    runs-on: ubuntu-latest
    outputs:
      has_changes: ${{ steps.check.outputs.has_changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Check for recent commits
        id: check
        run: |
          COMMITS=$(git log --oneline --since="25 hours ago" | wc -l)
          if [ "$COMMITS" -gt 0 ]; then
            echo "has_changes=true" >> $GITHUB_OUTPUT
          else
            echo "has_changes=false" >> $GITHUB_OUTPUT
            echo "No commits in the last 25 hours. Skipping."
          fi

  build:
    needs: check-changes
    if: needs.check-changes.outputs.has_changes == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: npm run build

The 25 hours ago window (instead of 24) gives a buffer for schedule delays. GitHub warns that scheduled events can be delayed during periods of high load, especially at the top of the hour. The check-changes job costs about 1 billed minute regardless, but that’s 17 minutes saved on every no-change day.

Fix 2

Should you run scheduled CI daily or weekly?

Weekly is enough for most scheduled CI jobs. Dependency audits, license checks, and full integration suites rarely need daily runs. Most scheduled workflows run daily only because someone picked 0 0 * * * when they set it up and nobody revisited the frequency. Ask: if this job catches a problem, does it matter whether you find out at midnight or on Monday morning?

Runs 365 times/year
on:
  schedule:
    - cron: '0 0 * * *'  # Every day
Runs 52 times/year
on:
  schedule:
    - cron: '30 3 * * 1'  # Mondays at 03:30 UTC

Going from daily to weekly is an 85% reduction in runs. For an 18-minute job on Linux, that’s 540 → 78 minutes/month, saving 462 minutes per workflow. Also avoid scheduling at the top of the hour (0 * * * *) because GitHub Actions experiences peak load at :00 and your job may be delayed or dropped entirely. Use an offset like :30 or :15.

One caveat: scheduled workflows only run on the default branch. If you need to validate a long-lived release branch, you can’t use schedule directly, so use workflow_dispatch triggered by an external cron instead.

Fix 3

How to replace cron with event-driven triggers

Use the push event with paths: filters instead of a cron schedule. The underlying need behind most scheduled workflows is “run this when something changes,” which GitHub Actions handles natively. Dependency audits should run when lockfiles change. Security scans should run on push to main. Integration tests should run after deploys.

.github/workflows/security-audit.yml
name: Security Audit

# Before: ran every night regardless of changes
# on:
#   schedule:
#     - cron: '0 0 * * *'

# After: runs only when dependencies actually change
on:
  push:
    branches: [main]
    paths:
      - 'package-lock.json'
      - 'yarn.lock'
      - 'pnpm-lock.yaml'
      - 'Gemfile.lock'
      - 'requirements.txt'
      - 'go.sum'
  schedule:
    - cron: '30 3 * * 1'  # Weekly fallback for new CVEs
  workflow_dispatch:           # Manual trigger for on-demand runs

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high

This pattern gives you three triggers: immediate feedback when lockfiles change (using path filter patterns), a weekly safety net for newly disclosed CVEs, and a manual button for on-demand runs. The daily cron is gone. On a repo that merges 3–5 lockfile changes per month, this drops from 30 runs/month to roughly 8.

For dependency scanning specifically: check whether GitHub’s built-in Dependabot already covers your use case. Dependabot alerts are free on all plans and scan every push automatically. If you’re running npm audit or pip-audit on a cron because you set it up before Dependabot existed, you may be paying for a duplicate of a free feature.

Fix 4

What is workflow_dispatch and when should you use it?

The workflow_dispatch event adds a “Run workflow” button to the GitHub Actions tab, with optional input parameters. Use it to replace daily crons for jobs that only need to run when someone actually wants the results. Pair it with a weekly schedule fallback to keep a safety net without the daily cost.

.github/workflows/full-integration.yml
name: Full Integration Suite

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production
  schedule:
    - cron: '0 4 * * 1'  # Weekly Monday fallback

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/integration-tests.sh ${{ inputs.environment || 'staging' }}

This keeps a weekly backstop while giving the team the ability to trigger a run on demand. If the team runs it manually 2–3 times a week when they need it, that’s still fewer runs than a daily cron, and every run produces results someone actually wants.


Reference

How to audit your scheduled workflows

Run this checklist against every schedule-triggered workflow in your organization. For each one, decide: keep, reduce, replace, or remove.

Question Action
Failure rate < 2% over 90 days? Reduce to weekly or remove entirely
Duplicates a built-in feature? Remove and use Dependabot, CodeQL, or secret scanning instead
Could trigger on file changes instead? Replace cron with paths: filter on push
Runs on days with no commits? Add a change-detection gate (Fix 1)
Team runs it manually when needed? Switch to workflow_dispatch with weekly fallback
Failures go unnoticed for days? Fix alerting first, or remove the job

One more thing: GitHub automatically disables scheduled workflows on public repositories after 60 days of no repository activity. For private repos, there’s no auto-disable, so orphaned cron jobs in inactive private repos run forever, silently consuming your minutes allocation.

Related guides

Guides / Scheduled jobs that don't earn their keep

Find which scheduled jobs aren't pulling their weight

CostOps tracks per-workflow cost, run frequency, and failure rates for scheduled and event-driven workflows. See which cron jobs to cut before you change the YAML.

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

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