Building Neo Brunel, Our Self-Healing Package Builder

Package builds fail. That's normal. The annoying part is working out why they failed, fixing them and then remembering exactly where they were supposed to go next.

PikaOS rebuilds the Debian archive with our own optimisations. We also maintain software that Debian doesn't carry, build for several architectures and keep up with Debian Sid. While writing this, our builder was tracking 42,638 source packages. At that size, "we'll check the failed job later" isn't much of a system.

Until recently, the work was split between three tools. Brunel watched for new versions and started builds, Repomanager dealt with the finished packages, and Raven's agents watched our own software for releases. They worked, but none of them knew the whole story.

So we replaced them with Neo Brunel.

One System for the Whole Job

Neo Brunel's core is written in Rust. It compares versions, runs the queues, collects packages, checks dependencies and controls promotion between repositories. Our Gitea Actions runners still do the actual compiling. A separate agent service handles release checks and build repairs, and the web interface puts all of that in one place.

Neo Brunel dashboard showing the live build queue, source-package totals, agent activity and repository publish health

Each package page tells us which version Neo Brunel found, which runner built it, what was published, why something is waiting and whether an agent has touched it. When a job fails, the next bit of work starts with that history instead of another trawl through three services.

During the day we took the screenshot above, Neo Brunel completed more than 250 builds and recorded hundreds of agent runs.

Getting Built Is Not Getting Released

A package that compiles successfully still has a long way to go.

Fresh Debian rebuilds land in canary first. They stay there for at least three days, but the timer isn't an automatic pass. Neo Brunel checks that a newer version isn't already queued or building, that every expected binary exists and that all required architectures finished. Packages on our i386 list need both amd64 and i386 results.

It then works out whether the batch is installable as a whole. This is especially useful during library transitions. Moving a new library on its own can break every application linked against the old one, so Neo Brunel follows the reverse dependencies and keeps the rebuilds together. Either the group passes or it waits.

Flow chart showing a package moving through build, three days in canary, graduation checks, human approval, fourteen days in testing and a checkpoint into stable

Once those checks pass, one of us reviews the report and approves the move. We can do that in the builder or from Discord. Anything changed by an AI repair always stops here.

Approved packages enter the pika-testing APT distribution, which testers can use through nest-testing. The next wait is fourteen days. A package can be held if somebody finds a regression, and Neo Brunel runs migration checks plus clean install and upgrade simulations before including it in a checkpoint.

Neo Brunel Testing page showing the nest-testing fourteen-day soak, next checkpoint, ready count and per-package progress

Every two weeks, Neo Brunel prepares a checkpoint from packages that finished the soak. The report goes to the builder and Discord, then sits through a 24-hour veto window. We can hold one package or stop the checkpoint completely.

If it goes ahead, Neo Brunel snapshots nest, moves the approved groups out of nest-testing, checks the published repository and refreshes it. If we find a problem afterwards, the snapshot gives us somewhere known to roll back to.

Where the AI Fits

We do use AI in Neo Brunel, but we give it a small job and hard limits.

When a build fails, the build scripts save a structured result: the failed stage, an error class and the useful end of the log. The fixer reads that instead of guessing from a random error line. It first decides whether changing the package could help. A broken runner or unavailable source goes straight to a person.

If the package is ours, the fixer can prepare a packaging branch and pull request. For a Debian or deb-multimedia rebuild, it writes a normal versioned quilt patch in the matching upstream-patches repository. That keeps the repair visible and reproducible, and the patch is still there when the next version arrives.

Neo Brunel then runs the same build again. A second failure becomes the input for the next attempt. After three attempts it stops, attaches what it found and asks for help.

Flow chart showing Neo Brunel reading structured build results, preparing a restricted repair, retrying up to three times and stopping for human review

The agents also help with routine version work. A version scout finds new upstream releases. The dependency bumper prepares version, changelog and dependency changes. Nothing is built until one of us approves it.

Neo Brunel Approvals page showing AI-prepared package updates waiting for Reject or Approve decisions

That restriction isn't left to the prompt. Agents don't have tools that can promote a package or publish stable, and the core requires a human approval record before it will do either. A model can read logs, write a patch and save us time. It can't decide what becomes PikaOS.

Why We Also Wrote Roost

Neo Brunel still needed a repository manager. We started with Aptly because it already ran our Debian repositories, but publishing had become one of the slowest parts of the process.

On our repository host, Aptly was using around 5 GiB of memory. Publishing could take minutes even after a small change, and much longer on the biggest repositories, because it repeatedly walked and rewrote metadata. We had disabled Contents indexes to keep publishing manageable. PDiffs and by-hash indexes were also missing, while LevelDB locking made it awkward to mix API work with an emergency command-line fix.

The repository manager itself needed replacing, so we wrote Roost.

Roost is a Debian repository manager written in Rust. It stores packages in a content-addressed pool and keeps the metadata it parsed when each package arrived. Publishing one new build doesn't mean opening every old package again.

It writes Packages, Sources and Contents indexes, along with PDiffs, by-hash paths and our DEP-11/AppStream metadata. A new dists tree is built away from the live one and moved into place together, so a failed publish can't leave half of the new repository visible.

We made Roost compatible with the part of Aptly's HTTP API that Neo Brunel uses. The builder didn't need another repository client; we changed the endpoint and kept the same calls. Roost can also import our Aptly configuration and package memberships while reusing the existing package pool.

In our 200,000-package Contents test, Roost used about 126 MiB of memory. That test and Aptly's live 5 GiB figure aren't the same workload, so it isn't a clean benchmark. It did show that we could turn the indexes we wanted back on without putting the repository host under the same pressure.

What This Means for PikaOS

Most people using PikaOS shouldn't need to think about its package builder.

For us, Neo Brunel means fewer mornings spent piecing together a failed build from several services. Version checks and safe repair attempts can happen as soon as the evidence is available. Library transitions stay together, every package move has an audit trail and releases still stop for one of us to approve them.

For people using PikaOS, the aim is less exciting but more useful: fewer partial transitions and fewer dependency surprises. Testers can see what is soaking, what is held and when the next checkpoint is due. The public builder shows the same queues, package history, approval state and checkpoint reports that we use.

Both pieces are open source. You can read Neo Brunel, look through Roost or watch the package flow in the builder.

Neo Brunel can notice a new version, build it, diagnose a failure and prepare a repair. We kept the final yes or no.