
From Provision to Shutdown: The Lifecycle of a Tenki Runner


Most teams treat their GitHub Actions bill as a fixed cost. It isn't. Path filters, concurrency cancellation, job splitting, and runner selection routinely cut billable minutes by 40–70% without sacrificing test coverage. Here's how to audit your workflows and find the savings.
Your GitHub Actions bill is probably higher than it needs to be. Not because you're doing anything wrong, exactly, but because the defaults are generous with your minutes. Every push triggers every workflow. Stale runs from force-pushed branches keep burning time. macOS and Windows jobs quietly cost 10x and 2x what Linux does. And nobody notices until the invoice arrives.
I've seen teams cut their billable minutes by 40–70% with a handful of targeted changes. No test coverage sacrificed, no developer experience degraded. Just less waste. This guide walks through every lever you can pull, starting with understanding what you're actually paying for.
Before optimizing anything, you need to understand the billing model. GitHub charges for Actions usage in billable minutes, but not all minutes are equal.
Each GitHub plan includes a bucket of free minutes per month. Free plans get 2,000 minutes, Team gets 3,000, and Enterprise gets 50,000. Once you burn through the included allotment, you're paying per-minute overage. For Linux runners, that's $0.008 per minute. But here's where it gets expensive: macOS minutes are billed at a 10x multiplier and Windows at 2x. A 10-minute macOS job doesn't cost 10 minutes from your bucket — it costs 100.
The per-minute rates for overage on GitHub-hosted runners break down like this:
There's also a rounding trap. GitHub rounds each job's duration up to the nearest minute. A job that takes 1 second and a job that takes 59 seconds both cost 1 minute. This matters more than you'd think when you have lots of small jobs.
Larger runners (4-core, 8-core, etc.) introduced in 2023 have their own per-minute rates that scale with the CPU count. A ubuntu-latest-4-cores runner costs $0.016/min — double the standard Linux rate. The 8-core variant is $0.032/min. Whether the faster execution offsets the higher rate depends on how parallelizable your workload is.
This is the single easiest win. By default, a workflow triggered on push or pull_request fires on every commit regardless of what changed. Someone fixes a typo in the README? Full CI suite runs. Updated a license file? Full CI suite runs.
The paths filter lets you restrict which file changes actually trigger the workflow. Its inverse, paths-ignore, excludes specific paths. Use whichever is shorter for your repo.
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/ISSUE_TEMPLATE/**'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/ISSUE_TEMPLATE/**'
- 'LICENSE'For monorepos, you can get more aggressive. If your frontend and backend live in separate directories, you can create separate workflows that only trigger on changes to their respective paths:
# .github/workflows/backend-ci.yml
on:
push:
paths:
- 'packages/api/**'
- 'packages/shared/**'
# .github/workflows/frontend-ci.yml
on:
push:
paths:
- 'packages/web/**'
- 'packages/shared/**'Note the shared package in both. That's the common gotcha: you need to include paths for any shared dependencies, or you'll skip builds when shared code changes and only discover the breakage later.
One caveat: if you use path filters on required status checks for branch protection, GitHub won't let you merge when the check is skipped. The fix is to set paths-filter at the job level as a conditional instead of the workflow trigger level, so the workflow still runs (satisfying the status check) but individual jobs skip.
Here's a pattern every active team hits: a developer pushes a commit, CI starts. They spot an issue, push a fix, CI starts again. Now two runs are going for the same PR, and the first one's results are already irrelevant. Multiply this across a team and you're burning minutes on runs nobody will ever look at.
The concurrency key with cancel-in-progress: true fixes this. When a new run starts with the same concurrency group, the old run gets cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueThe group key here uses the workflow name and branch ref, so each branch gets its own concurrency lane. Pushes to feature-branch-A cancel previous runs on feature-branch-A but don't touch feature-branch-B.
Be careful with this on your default branch, though. If you cancel in-progress deployments on main, you might leave a deploy half-finished. A safer approach for main is to omit cancel-in-progress (which queues instead of cancels) or use a conditional:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}On a busy team with 20+ PRs in flight, concurrency cancellation alone can save 20–30% of total minutes. It's three lines of YAML and there's almost no reason not to add it.
Some jobs are expensive and don't need to run on every single push. Integration tests against a staging database, end-to-end browser suites, performance benchmarks — these might take 20+ minutes and only matter when you're actually close to merging.
GitHub's environment protection rules let you require manual approval before a job runs. Combined with workflow_dispatch for manual triggers, you can structure workflows so cheap checks (lint, type check, unit tests) run automatically, while expensive suites wait for a human to click "approve."
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
e2e-tests:
needs: unit-tests
runs-on: ubuntu-latest
environment: staging # requires approval in repo settings
steps:
- uses: actions/checkout@v4
- run: npx playwright testTo set this up, go to Settings → Environments, create an environment called staging, and add required reviewers. The e2e job will show as "Waiting" in the Actions UI until someone approves it. No approval, no billable minutes burned.
The tradeoff is workflow. If your team forgets to approve, the e2e tests never run and regressions slip through. A good middle ground: run the expensive suite automatically only on PRs targeting main, and gate it behind approval on feature branches.
Splitting one big job into parallel matrix jobs seems like it would cost more minutes. More runners, more setup time per runner, right? Sometimes. But the minute math isn't always intuitive because of that rounding behavior.
Consider a test suite that takes 18 minutes serially. That's 18 billable minutes. Now split it into 4 parallel shards. Each shard takes about 5 minutes (4.5 minutes of tests plus some setup overhead). That's 4 × 5 = 20 billable minutes. You're paying 2 extra minutes for faster feedback. Not a great deal if cost is all you care about.
But here's where it flips. If each shard finishes in 4 minutes and 10 seconds, rounding pushes each one to 5 minutes — 20 total. If you can optimize each shard to finish at exactly 4 minutes, it's 16 total. Less than the serial run. The key variables are:
The sweet spot for matrix parallelism depends on your test suite's total duration and how evenly you can distribute work. Here's a matrix configuration that works well for many Node.js projects:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest --shard=${{ matrix.shard }}/4Tools like Vitest, Jest, and Playwright all support shard flags natively. For balanced sharding, some CI tools (like Knapsack Pro or Currents) provide dynamic test splitting based on historical timings, which keeps shards roughly equal and minimizes rounding waste.
Self-hosted runners don't consume billable minutes at all. That's the appeal. But they're not free — you're paying for the compute, the maintenance, and the engineer time to keep them running. The question is whether your volume justifies the overhead.
The math is straightforward. Take your monthly GitHub-hosted cost:
monthly_cost = overage_minutes × rate_per_minute
Then estimate your self-hosted cost. For a dedicated EC2 instance (say, c5.2xlarge at ~$0.34/hour on-demand, or ~$0.13/hour reserved):
self_hosted_cost = instance_hours × hourly_rate + maintenance_hours × engineer_hourly_rate
That maintenance term matters. Self-hosted runners need patching, monitoring, and occasional debugging when jobs fail for infrastructure reasons rather than code reasons. For a small team, budget 2–4 hours per month of engineer time. For a larger setup with auto-scaling (using something like actions-runner-controller on Kubernetes), it could be more upfront but less ongoing.
Let's work through a concrete example. Suppose your team uses 15,000 Linux minutes per month on GitHub Team plan (which includes 3,000 free). That's 12,000 overage minutes at $0.008/min = $96/month.
A reserved c5.xlarge (4 vCPUs, 8GB RAM — comparable to a standard GitHub runner) costs about $65/month with a 1-year reserved instance. Add $150/month for 2 hours of engineer time at $75/hour. Total: $215/month. At $96/month in GitHub-hosted overage, self-hosting doesn't make sense.
Now scale it up. 80,000 minutes/month on Enterprise (50,000 included). That's 30,000 overage minutes = $240/month. With 3 reserved instances handling the load ($195/month compute) and the same $150/month maintenance, you're at $345. Still not clearly worth it.
The break-even typically happens at very high volume or with macOS/Windows workloads. If you're burning 20,000 macOS minutes per month, that's 200,000 billed minutes equivalent, or $1,600/month in overage. A Mac Mini in a colo (or a Mac Studio running in your office) costs $50–100/month in hardware depreciation plus power. The savings are obvious.
The general rule: optimize your GitHub-hosted usage first with the techniques in this article. Only look at self-hosting when you've squeezed out the easy wins and your overage bill is still substantial, or when the 10x macOS multiplier makes the math obviously lopsided.
The default timeout for a GitHub Actions job is 6 hours. Let that sink in. If a test hangs, it'll sit there burning minutes for 360 minutes before GitHub kills it. On a macOS runner, that's 3,600 billed minutes from a single stuck job.
Set explicit timeouts on every job:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: npm testSet the timeout to roughly 2x your expected job duration. If tests normally take 8 minutes, a 15-minute timeout catches hangs quickly without killing legitimate slow runs.
For matrix strategies, also consider fail-fast: true (which is actually the default). When one shard fails, the others get cancelled. If you're running 8 shards and one fails at minute 2, you don't burn minutes on the other 7 finishing their runs when you already know the build is broken.
Audit your workflows and ask: does this job actually need to run on macOS or Windows? Lots of teams run cross-platform matrix builds out of habit when only their release builds actually need multi-platform testing.
A common pattern that saves money: run your full test suite on Linux for every PR, but only run the macOS/Windows matrix on pushes to main or on release tags.
jobs:
test:
strategy:
matrix:
os:
- ubuntu-latest
- ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'macos-latest' || '' }}
- ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'windows-latest' || '' }}
exclude:
- os: ''
runs-on: ${{ matrix.os }}Admittedly that YAML is a bit ugly. A cleaner approach is to split it into two workflows: one that always runs on Linux, and one that runs the cross-platform matrix only on main pushes or tags. Either way, the savings are significant. If 30% of your minutes are macOS, moving those to Linux-only on PRs cuts total spend by about 27% (since macOS minutes cost 10x).
All the optimizations above are pointless if you're not tracking usage. GitHub provides several ways to monitor your Actions consumption.
Under your organization's Settings → Billing → Actions, you can see minutes used this billing cycle broken down by repository and by runner OS. This is the first place to look when you suspect waste. Sort by repo and look for repositories consuming disproportionate minutes. You'll usually find one or two workflows responsible for the bulk of the spend.
For programmatic access, the GitHub REST API exposes billing data. The GET /orgs/{org}/settings/billing/actions endpoint returns total minutes used, included minutes, and paid minutes for the current billing cycle. You can also get per-repository breakdowns with the workflow usage endpoints.
# Get org-level Actions billing summary
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/orgs/YOUR_ORG/settings/billing/actions | jq .
# Get workflow-level timing for a specific repo
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/YOUR_ORG/YOUR_REPO/actions/workflows/ci.yml/timing | jq .GitHub lets you set a spending limit (including $0 to hard-stop at the free tier). But a spending limit that kills your CI is a blunt instrument. A better approach is to set up a scheduled workflow that checks your usage against a threshold and sends a Slack notification or creates an issue when you're approaching your limit:
name: Actions usage alert
on:
schedule:
- cron: '0 9 * * 1' # every Monday at 9am UTC
jobs:
check-usage:
runs-on: ubuntu-latest
steps:
- name: Check Actions minutes
env:
GH_TOKEN: ${{ secrets.BILLING_TOKEN }}
ORG: your-org
THRESHOLD: 80 # alert at 80% usage
run: |
BILLING=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/orgs/$ORG/settings/billing/actions)
USED=$(echo $BILLING | jq '.total_minutes_used')
INCLUDED=$(echo $BILLING | jq '.included_minutes')
PCT=$(( USED * 100 / INCLUDED ))
echo "Usage: $USED / $INCLUDED minutes ($PCT%)"
if [ $PCT -ge $THRESHOLD ]; then
echo "::warning::Actions minutes at ${PCT}% of included allotment"
# Add Slack webhook or issue creation here
fiNote that the billing token needs admin:org scope to access the billing endpoints. Use a fine-grained PAT scoped to just the organization billing permission if possible.
If you're staring at an unexpectedly high Actions bill and want to prioritize, here's the order I'd attack it in:
Most teams will get the bulk of their savings from steps 1–4. They're quick to implement, don't require any infrastructure changes, and the risk of breaking something is low. Steps 5–8 require more thought and team buy-in, but they're where the remaining savings live if you need them.

From Provision to Shutdown: The Lifecycle of a Tenki Runner

What Are GitHub Actions Runners? A Complete Beginner’s Guide to CI/CD Workflows

How to Migrate from GitHub-hosted Runners and Save on Cloud Costs in 3 Minutes
Get Tenki
Change 1 line of YAML for faster runners. Install a GitHub App for AI code reviews. No credit card, no contract. Takes about 2 minutes.