CI for a Decentralized Forge: Why I Ended Up Bridging Radicle and Tekton

Table of Contents

cover Image

A few months ago I wrote about GitHub’s independence — short version, migrating from one centralized forge to another isn’t a strategy, it’s a rebrand. Recently I followed up with a review of Euro-Office and LaSuite, making the same point about European “sovereign” office suites being developed on GitHub. At the end of that article I promised I’d come back to this topic soon.

Here we are. This first post is about the why: why CI matters even when you’ve moved your git off the mainstream forges, why decentralizing CI is harder than decentralizing storage, and how I ended up picking Tekton after looking at some alternatives. The nuts and bolts of how the Radicle broker, my adapter, and the Tekton cluster talk to each other are worth their own write-up, and I’ll probably come back to them once the setup has more mileage.


Why I even started

I’ve liked Radicle since the first day I used it. Beyond being a credible decentralized alternative to the mainstream forges, the team has managed something surprisingly rare in the space: an experience that makes the decentralization almost transparent to the user. The CLI is polished, the TUI is a joy, and Radicle Desktop has been genuinely good for a while now.

But as a security engineer who spends his days in infrastructure-as-code, the lack of CI was a dealbreaker for me. Not because CI is philosophically important — because it’s the thing that separates “a project I push to” from “a project I actually ship.” It’s easy to git remote add a Radicle URL, push, and call yourself decentralized. Ten minutes. What really matters nowadays is what comes after the push — the invisible machinery that turns “I committed” into “the thing shipped and I know if I broke it.” And GitHub Actions and GitLab CI aren’t features people tolerate — they’re half the reason people are on those platforms in the first place. You can leave the source-hosting silo, but if you leave the CI behind you haven’t really left. You’ve just moved the lock-in one layer up the stack.

The good news is that the Radicle team saw this coming. They shipped a broker/adapter protocol1 that lets any external CI system react to Radicle events over a small stdin/stdout JSON contract. The connective tissue is there — it just needs to be wired it to a CI engine.

That’s what I set out to do: check whether the sovereign-stack story I’ve been writing about was actually shippable end-to-end today. Take a project, host it on a peer-to-peer forge, and still get the modern-software niceties (tests on every push, container images built and pushed to a registry, status visible somewhere) without a US-hosted control plane in the loop. It turns out you can.


Why decentralizing CI is harder than decentralizing storage

Here’s the piece I didn’t fully appreciate until I was elbow-deep in it. Convincing people to run a Radicle seed node — a small, mostly idle process that mirrors other people’s repos so the network stays healthy — is already a stretch. Storage is cheap, bandwidth is bursty, and yet the number of public seeds that mirror a given random project is small.

Now imagine convincing strangers to run a CI compute node for you. That’s not a background daemon idling at 2% CPU — it’s a machine spinning up containers, running your (potentially arbitrary) test code, pulling gigabytes of dependencies, pushing gigabytes of layers to a registry, with predictable latency. Real money to run, a security nightmare with untrusted workloads, no altruistic argument for why someone else should shoulder your build minutes.

Storage-as-a-commons kind of works because storage is cheap and mostly write-once. Compute-as-a-commons has been tried repeatedly (BOINC, Golem, various blockchain flavors) and hasn’t crossed into general-purpose CI. So the pragmatic model, at least today, is: you run your own CI compute, and you own it end to end.

That’s exactly what I did, and I want it to make it clear and honest. My CI stack proposal is centralized: one Talos VM, one operator, one physical location. The article isn’t about “decentralized CI”; it’s about CI that runs cleanly inside a decentralized forge environment.

The framing works because the CI stack is disposable. The code lives on Radicle, replicated across every seed that has chosen to mirror it — my own seed is just one of them. If I stop seeding tomorrow, the repo doesn’t disappear; the peers that have it keep it. The CI cluster is the throwaway part: a Talos VM plus a handful of manifests, standable-up in under an hour on any machine that can run Kubernetes. Rebuild it elsewhere, regenerate the CI-user keys, plug it back into the network, and the pipelines fire again on the same code they were firing against before. A truly decentralized CI — compute as a commons — is its own hard problem. Until it’s here, “the persistent forge is decentralized, the ephemeral compute is mine” is an honest trade-off.


The alternatives I looked at

Before landing on Tekton I did the honest survey. Not exhaustive, but enough to convince myself I wasn’t reinventing the wheel.

Radicle’s own native CI story. There’s an experimental broker/adapter pattern1 shipped by the Radicle folks themselves, with a reference native adapter that runs pipelines as plain shell scripts on the seed. It’s the right primitive to build on, and it’s the protocol my own adapter plugs into. On its own though it stays fairly light — enough for cargo test-style checks, but probably not the shape that will win over teams used to a fully-featured GitHub Actions or GitLab CI workflow. It’s more of a foundation to grow richer engines on top of than an engine in itself.

Concourse. I have a soft spot for Concourse — its pipeline-as-YAML model is one of the cleanest I’ve used and its “resources” abstraction is genuinely elegant. What ruled it out was the ops shape: web + worker + Postgres, BOSH-flavored deployment, a stateful database whose backups you’d have to think about, and the community has been shrinking for years.

Woodpecker, Drone, and friends. Nice and lean, but designed around the assumption that there’s a forge with a webhook API and OAuth on the other end. Radicle has neither, by design. So much glue that the “lightweight” advantage evaporates.

Tekton. Kubernetes-native, with Pipeline and Task as first-class Custom Resources. The CI system is a set of YAML files in a cluster you already own. No SaaS control plane. No proprietary runner protocol. A pipeline is a kubectl apply away. There’s a real public catalog of reusable tasks (kaniko, git-clone, buildpacks). And crucially: there is no central “service” to depend on. If your cluster runs, your CI runs.

That last point is the one that mattered. Every other option — even the self-hostable ones — had something central-owned in the control plane: a web UI you log into, a scheduler that phones home, an update channel. Tekton is just Kubernetes CRDs. If Kubernetes is fine, Tekton is fine. Not the flashiest choice, not the nicest authoring UX (it’s YAML-heavy, the read-only Dashboard is fine and nothing more), but the coupling between “my CI” and “someone else’s platform” is genuinely zero.


The problems that come with that choice

Picking Tekton doesn’t make Radicle integration free. It means the remaining problems are ones you can solve with plumbing instead of begging a third party.

You have to actually run a Radicle node. Not a client that pushes to somebody else’s node — an honest-to-god seed with radicle-node and radicle-httpd alive and reachable, holding the repo you want CI to see. A small StatefulSet with a persistent volume and a handful of ports exposed, but a real ongoing process whose first-boot behavior (COBs cache migrations, key generation, socket setup) has sharp edges.

The runner has to rad clone inside a pod. You can pull the worktree with a plain git clone https://<seed-httpd>/<rid>.git, but you lose everything Radicle-specific in the process: no access to the patch and issue COBs, no cryptographic proof of who authored what, no way to publish CI results back onto the network. If you want the CI pod to be a real Radicle peer, it needs rad clone, which in turn needs a running radicle-node with a control socket. My solution was to spawn an ephemeral node inside the CI pod for the duration of the clone, backed by a dedicated CI identity that the repo has added as a delegate. Its startup latency (20–30 seconds for the ephemeral node to migrate its COBs cache and open the socket) is the single largest constant in every pipeline.

The broker doesn’t speak HTTP. Radicle’s CI-broker1 uses a line-oriented JSON protocol on stdin/stdout — no webhooks, no HMAC, no long-poll. The broker runs as a normal container in the cluster, but its adapters aren’t peer services it contacts over the network: they’re plain executables it fork/execs on every event, then talks to over pipes. Great security posture (no public webhook receiver on your domain, no service-to-service auth to manage) but it means your adapter has to live alongside the broker binary inside the same container and speak the v1 wire format on stdin. That’s what my small Rust adapter does: ships in the broker image next to the broker itself, reads the event from stdin, and creates Tekton PipelineRun resources directly via the Kubernetes API.

Results have to go back somewhere. Radicle has a concept of Job COBs — Collaborative Objects for CI job state — that are meant to sync back into the network alongside the patches they describe. The broker updates them if you tell it to. That closes the loop: CI status ends up in the same peer-to-peer data plane as the code. Still polishing that part on my side.

Each of these cost me an evening of surprised debugging. None are documented in one place. That’s the write-up I’d do next.


Wrapping up

What I have is an opinionated stack. Tekton over Concourse, direct PipelineRun creation over a webhook receiver, a dedicated CI identity over shared credentials, an ephemeral node inside every pipeline over a persistent sidecar. Every one of those choices is defensible; every one could also be redone differently by someone else with different priorities. The one dependency I’d swap first is Docker Hub — images produced by my pipelines land there because I didn’t want to add “run and operate a container registry” on top of everything else, and projects like Dragonfly already show that image distribution can go P2P. It’s on the list, not something this article claims to have solved.

What it does claim, and what I can now demonstrate, is that the stack actually works: it ships itself. The adapter’s own container image2, the radicle-node image3, the reusable Task catalog4, the Kubernetes manifests5, and the last version of a Rust client I maintain6 has been published with it — all of them are built, tested, and published by the very pipelines they define. Everything is availiable on https://radicle.siryu.me.

And it’s still centralized on one Talos VM I own — that’s the honest bit. The property that makes that survivable is that the whole thing is disposable: the code lives on Radicle, replicated across peers I don’t control; the CI cluster is a handful of manifests I can throw away and re-kubectl apply on a different machine in under an hour. The persistent forge is decentralized; the ephemeral compute is mine. That’s the trade-off I’m running with today.



  1. radicle-ci-broker is the upstream project that lets an external CI system react to Radicle events (pushes, patch updates, and so on) over a small stdin/stdout JSON protocol. It’s what my adapter plugs into. ↩︎ ↩︎ ↩︎

  2. radicle-tekton-adapter — the Rust adapter that receives events from the broker and creates Tekton PipelineRun resources in the cluster. ↩︎

  3. radicle-node-docker — pinned radicle-node + radicle-httpd container image, used by both the in-cluster seed and the ephemeral rad clone step. ↩︎

  4. radicle-tekton-catalog — reusable Tekton Tasks (rad-clone, kaniko, rust-lint, rust-test, cargo-publish) that repos reference from their .radicle/tekton.yaml↩︎

  5. radicle-tekton-ci — Kustomize manifests for the whole stack: Radicle node, radicle-ci-broker, Tekton Pipelines, read-only Dashboard, Traefik ingress, secrets templates. ↩︎

  6. backstage-client — the crate I dogfooded the cargo-publish Task with. The .radicle/tekton.yaml in there is a two-trigger example (dry-run on patch, real publish on main). ↩︎