Rails 8.1's `bin/ci`: A Local CI Runner That Earns Its Keep

A pattern I've fallen into across more client codebases than I'd like to admit: write a feature, push the branch, switch to something else, get pinged ten minutes later that CI failed on a typo or a missing migration. Fix it. Push again. Wait. Repeat.

The cost of those loops is small individually and substantial in aggregate. Cloud CI services are good at what they do, but free tiers are often slow, and the context-switching tax of push-and-wait is real even on paid plans. If your dev machine is reasonably modern, it's usually faster than the runner you're waiting on.

Rails 8.1's Local CI feature is the framework's answer to this. It's a small, opinionated addition: a config/ci.rb file that describes your pipeline as Ruby, and a bin/ci script that runs it. Same definition, same intended checks, runnable locally before you push or in cloud CI after. The result is one source of truth and one fewer feedback loop you have to wait on.

It's the kind of feature that doesn't get a marketing campaign because the win is mostly about not being annoyed. Worth a proper look anyway.

What it actually is

When you generate a fresh Rails 8.1 app, or run bin/rails app:update on an existing one, you get two new files:

  • config/ci.rb: the pipeline definition.
  • bin/ci: the runner script.

A generated Rails 8.1 config/ci.rb looks broadly like this, though exact steps and commands can vary between Rails versions and application setup:

CI.run do
  step "Setup", "bin/setup --skip-server"
  step "Style: Ruby", "bin/rubocop"
  step "Security: Gem audit", "bin/bundler-audit"
  step "Security: Importmap vulnerability audit", "bin/importmap", "audit"
  step "Security: Brakeman code analysis", "bin/brakeman", "--quiet", "--no-pager", "--exit-on-warn", "--exit-on-error"
  step "Tests: Rails", "bin/rails", "test", "test:system"
  step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"

  if success?
    step "Signoff: All systems go.", "gh signoff"
  else
    failure "Signoff: CI failed.", "Fix the issues and try again."
  end
end

Run bin/ci and Rails executes each step in order, timing each one, capturing exit codes, and producing a colourised summary at the end. If any step fails, the overall run is marked failed and the failure summary appears at the bottom.

That's most of what there is to know. The DSL is small on purpose.

The DSL

The Ruby surface is straightforward:

  • step "name", "command" - defines a step. The first argument is the human readable name; the rest form the command to execute. You can pass the command as one string or as multiple arguments to avoid shell-escaping headaches.
  • success? - returns true if every prior step has passed. Useful for guarding steps that should only run on a clean build.
  • failure "message", "description" - prints a styled error block. It is commonly used after success? returns false; the failed prior step is what makes the overall run fail.
  • heading "title", "subtitle" - prints a section header in the output. Handy for grouping related steps in larger pipelines.

Under the hood, CI.run delegates to ActiveSupport::ContinuousIntegration.run. The implementation lives in Active Support, so this is framework-level infrastructure rather than a separate gem. The Local CI work was led by Jeremy Daer at 37signals, which makes sense once you've used it for a bit, it has the same "small, opinionated, gets out of your way" character as the rest of their Rails contributions.

One small but useful detail: when bin/ci runs, it sets ENV["CI"] = "true". Anywhere in your app that already branches on the CI environment variable, test setup, eager loading config, logger disabling, can behave consistently between local and cloud CI runs.

"Local" doesn't mean only local

The naming is slightly misleading. Local CI is the headline use case, pre push, on your own machine, but the design specifically supports running the same definition in cloud CI. A minimal GitHub Actions workflow can shrink to almost nothing:

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bin/ci

This is where the real value lands. config/ci.rb becomes your single source of truth for what "passing CI" means. The cloud runner is no longer a slightly different shell script that drifts out of sync with what you run locally, it's the same definition.

I've seen the drift problem on enough client codebases to take this seriously. The GitHub Actions YAML says one thing, the developer's pre push shell alias says another, the README says a third, and over a year or two they diverge. Consolidating to one file removes a whole category of bug.

When it earns its keep

In the apps I've used this on, the win is bigger when:

  • The test suite is fast. If bin/rails test takes ninety seconds, running the full pipeline locally before every push feels comfortable. If it takes twenty minutes, developers will quietly stop.
  • You have a noticeable security/static-analysis tail. Brakeman, bundler-audit, importmap audit, RuboCop — these add up. Running them once locally before pushing saves the round-trip when one of them catches something silly.
  • The team is small. With two or three engineers, "the developer runs CI locally and signs off" is a reasonable workflow. With twenty, you probably still want cloud-side enforcement as the hard gate.

When it doesn't

A few honest caveats:

  • Slow test suites are the killer. I mentioned this above and it bears repeating. If your suite is genuinely slow, the answer isn't to skip bin/ci; it's to make the suite faster first, then come back to this.
  • Hardware variance matters. A passing run on a recent laptop doesn't guarantee anything about a colleague's older one. Cloud CI provides a consistent baseline that local only setups don't.
  • System tests are still flaky. That's not Rails 8.1's fault, but running flaky tests locally is the same as running them in the cloud, flaky. The runner doesn't change that.

The gh signoff integration

The optional GitHub signoff piece is genuinely clever. The basecamp/gh-signoff extension to the gh CLI lets a successful local bin/ci run mark a GitHub commit status green. Combined with branch protection rules that require that signoff status before merge, you get: PRs can only merge when someone has signed off the latest commit after a successful local run. It's a trust-based workflow, not a tamper-proof substitute for remote CI.

It's a workflow with real opinions baked in. Whether you want them depends on the team. For a small, trusted team it's a clean replacement for cloud CI as the merge gate. For a larger or less trusted setup it makes more sense as a pre-merge check on top of cloud enforcement rather than instead of it.

What I'd hand a client this week

If I walked into a Rails 8.1 codebase tomorrow and Local CI wasn't being used, here's roughly what I'd try:

  1. Run bin/rails app:update if it hasn't been run since the 8.1 upgrade, and accept the new CI files if prompted. You should then pick up config/ci.rb and bin/ci.
  2. Align config/ci.rb with the existing CI workflow. Don't try to reinvent the pipeline — copy whatever the team currently runs in GitHub Actions or similar, get it green locally, then move on.
  3. Replace workflow-specific test and lint commands with - run: bin/ci where appropriate. Verify the cloud build still passes against the same definition.
  4. Try bin/ci before pushing for a week. See whether the feedback-loop benefit actually lands for your specific suite shape and team habits.
  5. If you have a small team and a fast suite, look at gh signoff. If not, skip it — the single-source-of-truth benefit alone is worth the change.

That's it. The feature isn't trying to replace your CI service. It's trying to make sure the definition of "passing CI" lives in one place and runs from the same definition, whether you're on your laptop or in the cloud. For most teams I work with, that's a small win that compounds quietly.


If you'd like a hand thinking through Rails 8.1 adoption, CI workflows, or making a Rails team's day-to-day a bit less push-and-wait, [drop me a line](mailto:wm@wagnermatos.co.uk). I do this kind of work on a fractional and contract basis.

Looking for a fractional Rails engineer or CTO?

I take on a limited number of part-time clients.

Get in touch