The generator template at Rails defines scan_ruby, scan_js, lint, test and system-test jobs. Every single one of them opens with its own Checkout code and its own Set up Ruby. That's a perfectly reasonable default: it's maximally parallel, each failure gets its own red warning.

However, parallelism buys you a shorter wait. It never buys you a smaller bill. GitHub Actions bills per job, by wall-clock, rounded up to the nearest minute.  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
separate jobs they paid three separate setups. Collapsing them into a single checks job means one
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
red run still reports all four results.

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
matters. A concurrency group fixes that. But we deliberately scope the cancelling to feature
branches. On main, every commit's result is worth keeping, so the expression turns cancellation
off there rather than applying it blindly.

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.

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

4) Move Dependabot from daily to weekly

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

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

5) Split the unit and system test phases

In our original setup, we ran 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 ran at all on a red build. It stayed invisible behind any unit failure and cost a whole extra CI cycle to discover. Two steps plus !cancelled() means both phases always report.

There's a catch worth knowing. SimpleCov checks its minimum_coverage floor at process exit, so 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.

      - 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
# test/test_helper.rb
minimum_coverage line: 98 if (ENV["CI"] || ENV["COVERAGE_MIN"]) && !ENV["SKIP_COVERAGE_MIN"]

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 token was already read-only, but only because that's the repository default, which is a setting someone can flip. Declaring it in the workflow makes the intent explicit and survives that change.

permissions:
  contents: read