---
title: Cutting GitHub Actions bill in a Rails app
url: https://calvin.my/posts/cutting-github-actions-bill-in-a-rails-app
published: 2026-09-08
updated: 2026-09-13
category: Operations
tags:
- GitHub
- ci
- Rails
summary: The post explains how a Rails team reduced private GitHub Actions costs without losing useful failure reporting. It combines short checks to avoid repeated runner setup, cancels outdated feature-branch runs, and adds job timeouts. It also reduces Dependabot frequency, separates unit and system tests so both results appear after failures while adjusting coverage checks, and explicitly limits workflow token permissions. The changes trade some parallelism for lower billed wall-clock time and more reliable feedback.
---

# Cutting GitHub Actions bill in a Rails app

The generator template at [Rails](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt) defines `scan_ruby`, `scan_js`, `lint`, `test` and `system-test` jobs. Every single one of them opens with its own `Checkout code` and&nbsp;its own `Set up Ruby`. That's a perfectly reasonable default: it's maximally parallel, each failure&nbsp;gets its own red warning.

However, parallelism buys you a shorter wait. It never buys you a smaller bill.&nbsp;GitHub Actions bills per job, by wall-clock, rounded up to the nearest minute. &nbsp;On a private repo, all these land on the invoice.

This article documents some lessons learnt in our optimising journey.

* * *

## 1\) Merge check jobs into one

Brakeman, importmap audit, RuboCop and Herb are \~20 seconds of work between them, but as three&nbsp;separate jobs they paid three separate setups. Collapsing them into a single `checks` job means one&nbsp;VM, one checkout, one `Set up Ruby`. The one thing worth preserving is that a failing RuboCop  
shouldn't hide a failing Brakeman, so every step after the first gets `if: ${{ !cancelled() }}` . A&nbsp;red run still reports all four results.

```yaml
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      # ... checkout + setup-ruby, once ...
      - name: Scan for common Rails security vulnerabilities using static analysis
        run: bin/brakeman --no-pager

      - name: Lint code for consistent style
        if: ${{ !cancelled() }}
        run: bin/rubocop -f github
```

* * *

## 2\) Cancel superseded runs

Push twice in quick succession and you pay for both runs, even though only the second one's answer&nbsp;matters. A `concurrency` group fixes that. But we deliberately scope the cancelling to feature&nbsp;branches. On `main`, every commit's result is worth keeping, so the expression turns cancellation&nbsp;off there rather than applying it blindly.

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
```

* * *

## 3\) Put a timeout on every job

The default job timeout is 360 minutes. If you have a step that could "hang", for example a Playwright, it would quietly burn six billed hours. Setting a timeout to 2-3X your slowest ever run would make sense.

```yaml
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
```

* * *

## 4\) Move Dependabot from daily to weekly

Every Dependabot PR&nbsp;changes `Gemfile.lock`, and that is precisely the expensive path: `setup-ruby`'s bundler cache only&nbsp;matches a restore-key. It spends time restoring a stale cache and then recompiles every native&nbsp;extension from source. We change the frequency from daily to weekly.

```yaml
- package-ecosystem: bundler
  directory: "/"
  schedule:
    interval: weekly
```

* * *

## 5\) Split the unit and system test phases

In our original implementation, we ran&nbsp;`bin/rails db:test:prepare test test:system` as a single command. The unit phase aborts the process on failure, so the system tests never&nbsp;ran at all on a red build. It stayed invisible behind any unit failure and cost&nbsp;a whole extra CI cycle to discover. Two steps plus&nbsp;`!cancelled()`&nbsp;means both phases always report.

There's a catch worth knowing. SimpleCov checks its `minimum_coverage` floor at process exit, so&nbsp;splitting one process into two moves that check onto unit-only coverage. A dozen of browser tests would never clear the minimum coverage on their own. We leave the minium coverage guard on unit tests only and skip it on system test.

```yaml
- name: Run tests
  run: bin/rails db:test:prepare test

- name: Run system tests
  if: ${{ !cancelled() }}
  env:
    SKIP_COVERAGE_MIN: "1"
  run: bin/rails db:test:prepare test:system
```

```ruby
# test/test_helper.rb
minimum_coverage line: 98 if (ENV["CI"] || ENV["COVERAGE_MIN"]) && !ENV["SKIP_COVERAGE_MIN"]
```

The rails generated template gives system tests&nbsp;their own `system-test` job. The difference is&nbsp;a separate job pays for another VM.

* * *

## 6\) Pin the token to least privilege

This isn't a bug, just a precaution. Nothing in this workflow writes to the repository, it checks out code and runs analysis. The&nbsp;token was already read-only, but only because that's the repository default, which is a setting&nbsp;someone can flip. Declaring it in the workflow makes the intent explicit and survives that change.

```yaml
permissions:
  contents: read
```
