Skip to content
beatmadsenPublic

About

Opinionated local CI that checks every commit on your machine under strict time budgets, for you to watch in a terminal console and for your coding agent to ask

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Gem Version

Fun-CI

Opinionated local CI that checks your code before it leaves your machine. Every commit runs lint, build, a fast suite and a slow suite on your own computer, each held to a strict time budget, so the feedback stays fast.

The fun-ci console following a run: a rocket while it runs, then a scene as lint, the build, the fast suite and the whole run pass

Fun-CI has two goals. It should be extremely easy to tell whether all is well, and it should be fun. The design behind both is in docs/design.md.

There are two ways to use it, and both read the same results:

  • You watch. fun-ci console sits on a second screen. Colour and motion tell you from the corner of your eye that lint passed, the build passed, the fast suite passed, or that something failed and you should look closer. A pass gets a celebration, a failure gets an explosion.
  • Your agent asks. A coding agent can't glance at a screen, so it runs commands. fun-ci wait blocks until a commit's verdict is in and exits with it, and fun-ci why prints everything fun-ci kept about a failure, so the agent never has to rerun a suite to find out what broke.

Getting started

gem install fun_ci
cd your-project
fun-ci init --everything

This detects your project type, writes four stage scripts into .fun-ci/, installs a post-commit and a pre-push git hook, and checks the setup. It also adds a short section to your AGENTS.md (or CLAUDE.md, if that is the only one) telling coding agents what to do after a commit; run again after an upgrade, it brings that section up to date.

fun-ci init has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. docs/stacks.md shows how it recognises each one and the commands it writes; --stack go picks one by name. In a project it doesn't recognise, such as a new one with no code yet, it writes placeholder scripts that pass, and fun-ci check warns about each one until you replace it. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.

  • lint.sh: linter and static analysis
  • build.sh: compile or build
  • fast.sh: the fast suite (unit tests)
  • slow.sh: the slow suite (integration tests)

Each script gets the commit hash as its first argument, its stage's name in FUN_CI_STAGE, and exits 0 to pass.

Upgrading from 1.x? Read Upgrading to 2.0 first.

How a run works

Stage Budget Runs Blocks
Lint 30 s in parallel with build the push
Build 30 s in parallel with lint the push
Fast 10 s after lint and build pass the push
Slow 5 min in the background, beside the fast suite nothing

Lint and build run side by side on the commit's checkout, and then the fast and slow suites run side by side on what the build left, all in one worktree. Two stages running at once must not write the same files, so lint.sh works on the source code only and build.sh compiles everything the suites need, test code included. A linter that needs compiled code belongs in build.sh or fast.sh. The scripts fun-ci init writes keep to this; Stages side by side has the details.

A stage that overruns its budget is killed and reported as over budget, in yellow rather than red: a fast suite that takes 12 seconds has grown too heavy, which is a different problem from a failing test.

After each commit, the post-commit hook starts the pipeline in the background and returns at once, so a commit is never held up. The pre-push hook waits for the verdict of each commit you push and stops the push if lint, build or the fast suite failed. A commit whose run has already finished goes straight through. If fun-ci isn't installed, both hooks say so and let git carry on.

install-hooks leaves a hook another tool wrote alone, and fun-ci check warns that no commit is tested until that hook calls fun-ci. Git LFS's own hooks are the exception: fun-ci writes its hooks in their place, and they run git-lfs's after fun-ci's, so LFS objects still reach the remote. After that, git lfs install says the hooks exist and exits 2, since it accepts no hook but its own; there is nothing for it to do. git lfs update --force would put git-lfs's back, leaving fun-ci out, until you run fun-ci install-hooks again.

Each run happens in a git worktree of its own in fun-ci's state directory (~/.local/state/fun-ci/projects/, outside .git/, which tools such as Vite won't serve files from), checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as vendor/bundle or node_modules stay in a worktree from one run to the next, which keeps builds quick. A worktree fun-ci has just made has none of them yet, and one nobody used for a day has them as they were then, so in such a run lint and build get the slow suite's five minutes rather than 30 seconds, and fun-ci status says so on their lines. A run's build outputs that git ignores stay too, and are only as right as the commit that made them, so a worktree that last tested another branch can hand a run outputs its commit wouldn't make. List those outputs in .fun-ci/config, such as clean: [public/assets, dist], and fun-ci removes their ignored files when a worktree moves to a commit that doesn't descend from the one it last tested; that run's lint and build get the longer budget too. Two runs can go at once; set worktree_slots: 3 in .fun-ci/config for more, and fun-ci prune removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it without --follow-branch.

Results are kept in SQLite under $XDG_STATE_HOME/fun-ci/ (~/.local/state/fun-ci/ by default), shared by every project on the machine.

Daily and weekly jobs

Some checks take longer than the time between two commits: a mutation run, a soak test, a full audit of your dependencies. Put each in a script of its own under .fun-ci/daily/ or .fun-ci/weekly/:

.fun-ci/daily/mutation.sh
.fun-ci/weekly/soak.sh

The script's name is the job's name. Like a stage, it gets the commit's hash as its first argument and passes when it exits 0, and FUN_CI_JOB tells it which job it is. There is nothing else to configure.

Commits start jobs. When the post-commit hook runs a pipeline, it also starts the project's due jobs on the commit just made: a job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started, so a project nobody commits to runs none. The jobs take turns, spread evenly over the day: with four jobs, the first starts at once and each next one six hours later. job_spacing: 30m in .fun-ci/config sets the gap instead, and 0 starts them together. A job waiting its turn says when it starts.

Each job runs once at a time, in a worktree of its own beside them, at a lower priority than the stages, so it takes only the cores they leave idle and never holds up a commit's run. It has 24 hours. A newer commit never cancels a job, and a job's result changes no run, no streak and no exit code. The design has the rest of the rules.

A failed job keeps its evidence as a failed stage does; add entries under evidence: jobs: <name>: in .fun-ci/config to keep more. fun-ci check lists the jobs it found, and says which scripts it can't run.

fun-ci jobs lists the jobs, each with how its last run went, on which commit, and when it is due again. fun-ci why --job soak prints everything kept about the job's last run (--raw for its whole output, --json for a document), and exits as why does for a stage. fun-ci status names the jobs whose last run tested the commit you ask about, and any job failing on another commit, and fun-ci events says when a job is scheduled, starts and finishes. fun-ci cancel --job soak stops the job's run, as c does in the console, so it runs again on the next commit. None of them changes a commit's verdict. A job's name is letters, digits, ., _ and -, so those commands can be pasted as they are printed.

Watching: the console

fun-ci console

The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (the fast suite failed after 1.4s) and when. What needs you comes first, in one deep block; passed rows stay pale. It holds the twenty branches whose newest run changed last, so a failure on a branch nobody touches any more leaves it in time. Above them is a picture that tells you what is happening without reading a word.

The console's table on a busy board: a legend naming lint, the build, the fast suite and the slow suite over their columns of marks, three projects, a failed branch that conflicts with main in a deep red card, a timeout, a running branch and two pale passed ones

While a run runs, a rocket flies. Each milestone a run passes has its own set of scenes, so you can tell which one it was from across the room, and a failure has its own.

Lint passed: a teal sweep, a ripple or a spirit level The build passed: amber blocks, gears or a ringing anvil
A teal line sweeping across the header Two amber gears turning
The fast suite passed: a lightning strike, a very happy YAY or a jump to warp speed The whole run passed: fireworks, a trophy, dancing leprechauns or a sunrise
YAY in rainbow letters with balloons The sun rising over hills

Scenes queue and each plays to its end, so a failure never cuts a celebration short, and a run gets one explosion however many of its stages fail.

The console after a run's fast suite failed: an explosion reading BOOM over the runs

When the scenes are done, the header rests on how the latest run went, so a glance a minute later still tells you: a calm tick after a pass, a pulsing hazard sign after a failure. The top right keeps count of the passes in a row.

A tick over a calm teal horizon A pulsing hazard triangle

Five minutes after the latest run finished, the header goes quiet: a starry night, an aurora, fireflies, a fire in a medieval stone hearth while a storm rages at the window, a moonlit island with a lone palm, or snow falling on a pine forest, changing every five minutes while nothing runs. A small lamp in the corner stays green after a pass and flickers red after a failure.

A fire in a stone hearth, with a green lamp in the corner A moonlit island with a palm, with a red lamp in the corner

The daily and weekly jobs have a section of their own under the last project: each job's name, how often it runs, its mark, what its last run did on which branch and commit, and when it is due again (passed on main 3a1f9c2 · due in 14h), or that it runs on your next commit. When none of them failed or runs, the section folds into one pale line.

The daily and weekly jobs under the table: soak failed a day ago on wip/foo, with a red stripe, mutation running for over an hour, with a blue one, deps-audit passed and due again in 14 hours, and lint-deep due on the next commit

A branch that conflicts with the trunk says so on the line under its name, conflicts with main, and the header plays two strands braiding into a knot; when a later commit on the branch merges cleanly, the knot unties. A project whose trunk fun-ci last fetched over an hour ago, or couldn't fetch, says so beside its name.

Keys:

  • j and k, or the arrow keys, move the cursor down and up
  • c cancels the run under the cursor: one waiting to start at once, a running one once you answer y (n or Esc keeps it running); the footer offers it only while a run is running or waits to start. The cursor moves on past the last branch into the jobs, and c cancels a job too: one waiting its turn at once, a running one the same way; a cancelled job runs again on your next commit
  • q quits

The console is drawn in 24-bit colour when COLORTERM says the terminal has it (truecolor or 24bit), and in 256 colours otherwise. The pictures above are the renderer's output replayed in a terminal emulator, set in Menlo; in your terminal the text is in your terminal's font.

The drawing is done by a separate program, fun-ci-renderer, written in Rust. The gem for Linux (x86_64, aarch64, musl) and macOS (arm64, x86_64) includes it. On other Unix-like systems you get the plain gem, where every command except console works; install the renderer with cargo install fun-ci-renderer (Rust 1.88 or newer), or point FUN_CI_RENDERER at a copy you built. Windows is not supported, because fun-ci forks its background pipeline, but WSL runs the Linux gem. When something goes wrong between the two, the details are in .fun-ci/console.log.

Asking: commands for agents and scripts

Every commit's output ends with the command that gets its verdict:

fun-ci: testing 3f9c2ab. Verdict: fun-ci wait 3f9c2ab --need all --follow-branch

The section fun-ci init writes into AGENTS.md tells an agent to run that command in the background after each commit. Agent harnesses wake the agent when a background command exits, so it hears about a failure without having to remember to ask. With --follow-branch, a newer commit on the branch cancels the older run, and the wait moves on to the newer run's verdict.

status says where a commit's run stands and wait blocks until it is decided. Both exit with the verdict for the stages you need: --need build (lint and build), fast (the default) or all (the slow suite too).

Exit code Meaning
0 Passed
1 A needed stage failed
2 A needed stage ran over budget
3 Still undecided (wait --within 30s gives up at your deadline)
4 Superseded by a newer commit on the branch (wait --follow-branch moves on to it)
5 No run for the commit
6 Passed, but conflicts with the trunk (only with --trunk)
64 Usage error

A failed stage comes with its evidence. status and wait end with a digest and the command that shows the rest:

$ fun-ci status
fun-ci: 2330979 "Add shipping to the cart total" on main
  lint   passed         0.6s
  build  passed         0.3s
  fast   FAILED         0.3s
  slow   passed         0.5s (not needed)
fast failed:
    1) Failure:
  CartTest#test_total_includes_shipping [test/cart_test.rb:16]:
  Expected: 104.95
    Actual: 99.95
fun-ci why 2330979 fast

why prints how the stage ended, each failure with its whole message and its own output, what the extractors picked out of the output and the log files, and anything that went wrong collecting it. why --raw prints the whole output. Every command takes --json.

$ fun-ci why 2330979 fast
fun-ci: 2330979 "Add shipping to the cart total" on main
fast failed (exit 1) after 0.2s, budget 10s

Minitest's failures and errors (output:9-12), from section:minitest:
    1) Failure:
  CartTest#test_total_includes_shipping [test/cart_test.rb:16]:
  Expected: 104.95
    Actual: 99.95

The output's last lines, from output-tail:
  Run options: --seed 20502
  ...
  3 runs, 3 assertions, 1 failures, 0 errors, 0 skips
  rake aborted!
  Command failed with status (1)
  ...

The whole output: fun-ci why 2330979 fast --raw

fun-ci runs lists recent runs one line each, and fun-ci events --follow prints each run's events as JSON lines as they happen, for a supervising agent or a status bar.

Where your commit stands against the trunk

Each run also checks whether its commit would merge cleanly with your trunk: trunk: in .fun-ci/config if you name one, otherwise your remote's default branch. status and wait print where it stands, and for a conflict, the files and what to do about it:

$ fun-ci status
fun-ci: f473fdb "Add shipping to the cart total" on feat/cart
  lint   passed         0.2s
  build  passed         0.3s
  fast   passed         0.1s
  slow   passed         0.2s (not needed)
  trunk  conflicts    origin/main 0e15acf, fetched just now, 1 ahead, 1 behind
    lib/cart.rb
Conflicts with origin/main in 1 file. When the task is done, integrate: git pull origin main (or git pull --rebase origin main if the branch isn't shared)
fun-ci why f473fdb trunk

A conflict is not a failure, so that run still exits 0. An agent that should integrate before calling its work done asks with --trunk, and gets 6 for a run that passed but conflicts; fun-ci init tells agents to. fun-ci why f473fdb trunk merges again and prints each conflicted region with the lines around it.

fun-ci fetches the trunk itself, at most every five minutes, into a ref of its own (refs/fun-ci/trunk/...), so your origin/main and git status are left as they were. The first fetch says so. trunk_fetch: false in .fun-ci/config stops it, trunk_fetch: 30m spaces it out, and trunk: none turns the check off. A trunk fetched over an hour ago, or whose last fetch failed, is marked STALE wherever it is shown.

What fun-ci keeps when a stage fails

With no configuration, fun-ci runs the presets for the tools your project has when a failure shows them. There are presets for the test runners, compilers, linters and type checkers of every stack fun-ci init knows, and for JSON logs from logstash-logback-encoder, ECS, pino and structlog; docs/stacks.md lists each one with what it picks out and when it runs. Each preset is checked against the recorded output of a real failing run of its tool. fun-ci check lists the ones that apply to your project.

To keep more, add entries under evidence: in .fun-ci/config:

evidence:
  stages:
    fast:
      - use: junit-files         # each failure in the JUnit XML the stage wrote,
        paths: [build/test-results/test/*.xml]   # as file, line, test and message
      - use: log-file            # what the stage wrote to it, and only that
        path: log/test.log
        grep: ["ERROR"]
        context: 5
    slow:
      - run: .fun-ci/evidence/problems   # your own extractor, in any language:
        format: json                     # the context on stdin, JSON on stdout
      - run: pkill -QUIT -g "$FUN_CI_PGID" java   # a thread dump before an overrun is killed
        on: overrun

Beside the entries, evidence: takes a few settings: budget: 5 gives the extractors five seconds rather than two, detect: false stops fun-ci choosing presets from what the output shows, skip: [rspec] leaves out the presets named, mask: adds patterns of your own to mask, and masking: false turns masking off.

fun-ci extract fast --output saved.log runs a stage's extractors against a saved output (fun-ci why --raw > saved.log), so you can try an entry without making a commit. A mistake under evidence: never stops a pipeline: fun-ci check reports it and fun-ci why names it.

Secrets in the stage's environment are masked before anything is kept: the value of any variable whose name holds TOKEN, SECRET, PASSWORD, PASSWD, API_KEY, PRIVATE_KEY or CREDENTIAL, and GitHub, AWS and Slack tokens, private keys and Authorization: headers wherever they appear. The state directory is readable by your user alone.

Commands

fun-ci init                                     Scaffold .fun-ci/ for the detected project type
fun-ci init --everything                        init + install-hooks + check in one step
fun-ci init --stack NAME                        Scaffold .fun-ci/ for the stack named, such as go
fun-ci install-hooks                            Install post-commit and pre-push hooks
fun-ci install-hooks post-commit                Install a single hook type
fun-ci check                                    Verify .fun-ci/ setup
fun-ci console                                  Watch the runs
fun-ci status [commit] [--trunk]                Where a commit's run stands; the verdict is the exit code
fun-ci wait [commit] [--follow-branch]          Wait until that verdict is decided, then exit with it
fun-ci why [commit] [stage|trunk]               Everything kept about why a stage failed, or the conflict with the trunk
fun-ci runs                                     This project's recent runs, newest first
fun-ci jobs                                     This project's daily and weekly jobs and how each stands
fun-ci why --job <name>                         Everything kept about a job's latest run
fun-ci cancel --job <name>                      Stop a job's run; it runs again on the next commit
fun-ci events [--follow]                        The runs' events as JSON lines
fun-ci extract <stage> --output <file>          Try a stage's extractors on a saved output
fun-ci trigger <commit> <branch>                Run the whole pipeline in the foreground
fun-ci trigger --background <commit> <branch>   Start the pipeline in the background (what post-commit runs)
fun-ci prune                                    Remove fun-ci's worktrees when no pipeline is running

fun-ci --help lists every option, and fun-ci COMMAND --help a command's own.

Upgrading to 2.0

  • Run fun-ci install-hooks again. The background pipeline now runs after the commit (post-commit), so it tests the commit you just made; the pre-commit hook tested the one before it. Until you do, each run tests the commit before yours, and fun-ci check warns you. Installing removes the pre-commit hook fun-ci 1.x wrote and leaves a hook of your own alone (fun-ci check warns if yours still calls fun-ci). The new pre-push hook waits for the verdict instead of running the pipeline again.
  • The stages run in a worktree at the commit, not in your checkout. A script that relied on uncommitted files in your checkout won't see them any more.
  • The database moved from $TMPDIR to $XDG_STATE_HOME/fun-ci/. Runs recorded by 1.x are not carried over.
  • fun-ci-trigger and fun-ci-tui are gone; use fun-ci trigger and fun-ci console. fun-ci trigger --no-validate still works until 3.0, as the old name for --background.
  • The console needs fun-ci-renderer, which the platform gems include (see Watching: the console).

The whole list is in CHANGELOG.md.

Development

bundle install
bundle exec rake   # the gate: tests, the renderer's tests, the binary contract, rubocop, clippy

How it is built is in docs/architecture.md, and the requirements are in docs/acceptance-tests.md. The renderer is written in Rust; install Rust with rustup, which picks up the version the repository pins in rust-toolchain.toml. ruby script/readme_screenshots.rb draws the pictures in this README again, on macOS, after npm run --prefix script/screenshots/terminal setup once.

License

MIT

About

Opinionated local CI that checks every commit on your machine under strict time budgets, for you to watch in a terminal console and for your coding agent to ask

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages