plain.

Run Solidity tests in CI in seconds

Install plain with one command, run plain test in place of forge test, and keep three entries of ~/.plain in your CI cache. On Uniswap v4-core, pinned to 2 CPUs, a cold run took 1m43s with plain against 8m57s with forge 1.8.6, and a fresh clone with a restored cache took 4.9 seconds.

Tested on 29 October 2026 with plain 0.5.0 and forge 1.8.6 on Linux x86_64, on Uniswap v4-core at commit bea7f6d (39 test suites, via-IR, solc 0.8.26). Every command below was run for this page. The workflow file itself was not: I don’t have a hosted GitHub runner, so the commands ran on one machine whose processes were pinned to 2 CPUs (taskset -c 0,1) to come closer to a small CI runner.

The workflow

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: actions/cache@v4
        with:
          path: |
            ~/.plain/cache
            ~/.plain/solc
            ~/.plain/salt
          key: plain-$-$
          restore-keys: plain-$-
      - run: curl -fsSL https://getplain.org/install | sh
      - run: ~/.plain/bin/plain test

The checkout needs submodules: recursive because plain, like forge, reads lib/ as it is; only forge-std is built in. The installer takes about 3 seconds: it downloads the release for your platform, checks it against the release’s SHA256SUMS and unpacks one 11 MB binary into ~/.plain/bin. plain test exits with a non-zero code when any test fails, so the job fails as it would with forge. A [profile.ci] in foundry.toml is picked up with FOUNDRY_PROFILE=ci.

What the cache holds

plain keeps three things outside the project that are worth restoring between runs:

I checked the salt the hard way. Copying cache and solc into a new home directory and running a fresh clone still took 1m42s, because the cache didn’t match. Adding salt brought the same run down to 4.9 seconds. So the three entries go together, and the workflow above caches all of them.

Because a restored cache is trusted as if you had built it, restore it only in workflows you control.

Measured times

Each command was run once on the same machine, pinned to 2 CPUs, with plain and forge run one after the other on the same checkout. These are single runs, not averages, and your project and runner will differ.

Step plain 0.5.0 forge 1.8.6
Cold, nothing cached 1m43s 8m57s
Rerun, nothing changed 4.1 s 29.6 s
Add one test, run its suite 1.0 s 27.2 s
Fresh clone, restored cache 4.9 s n/a

One test plain failed that forge passed

plain’s run ended with 597 passed, 1 failed, and forge’s with 598 tests passed. The failing test is test_fuzz_getTickAtSqrtPrice_getSqrtPriceAtTick_relation, and plain fails it every time, with the input -887273. That input bounds to tick 887271, whose next tick’s price is exactly MAX_SQRT_PRICE, and TickMath.getTickAtSqrtPrice reverts with InvalidSqrtPrice on that value.

I wrote a three-line test that calls it with that tick, and forge fails it the same way. So the project’s fuzz test has an edge case that forge’s fuzzer, with the project’s seed of 0x4444 and 1,000 runs, didn’t reach and plain’s did. The times above are for a suite that goes red in CI under plain, because forge would fail that input too.

What plain doesn’t run

plain follows forge 1.x, so tests named testFail… are rejected with a message that says to use vm.expectRevert. On solmate (commit 89365b8) 278 of 568 tests failed for this reason in my run, and nothing else failed. A project that still uses testFail has to rename those tests before plain can run them.

plain also doesn’t write gas snapshot files or run coverage, so check what plain doesn’t do yet before replacing a CI step that depends on either.

Questions

Do I need Foundry installed in CI? No. plain downloads the solc versions your project needs, and forge-std is built in.

Does it use my foundry.toml? Yes: the solc version, optimizer, via-IR, remappings, fuzz and invariant settings and the rest of the Foundry settings it reads are used as they are.

Can I run both? Yes. plain writes only to .plain/ in the project, so forge’s out/ and cache/ are untouched and a job can run forge test next to it while you compare.

Where is the formatter check? plain fmt --check lists the files that aren’t formatted and exits 1, which is a gate for CI. On v4-core it took 45 ms in my run (forge fmt --check took 50 ms) and listed 30 of 132 files as not formatted.