Snapshot of the Radicle-only forge (cib + act, DinD staging registry) before the Gitea-centric v3 POC replaces it. Also carries the gateway's IMPORT_PUBLISHER mode, multi-segment IPNS paths and IPCR 1.2.0.
9.5 KiB
IPCR Forge — design notes
How the forge in Apps/IPCR-Forge/ goes from git push to docker pull, why it
is shaped this way, and what was learned building it (2026-10-04). The listing's own security
argument is in rationale.md.
Goal
A decentralised forge that works out of the box: Radicle is the single source of truth, builds use GitHub-style workflows (the developer comfort worth keeping), and images are served from IPFS through IPCR. Nothing in the chain is a second copy that can drift from Radicle.
Why Gitea was removed
The first version built with Gitea Actions. A bridge service copied every Radicle repository into Gitea, Gitea's runner built it, and IPCR imported from Gitea's registry. It worked end to end, but:
- Two places where state could originate. Gitea held a full copy of each repository, with its own PRs, issues and registry. A copy that is only ever derived is a cache; one where things can also start will desync.
- A glue service (the bridge) to keep the two in step, plus its credentials: an admin account,
two minted tokens, and a capability (
DAC_READ_SEARCH) IPCR needed only to read one of them. - What Gitea actually contributed was narrow: running Actions YAML, a web view of runs, and a staging registry. None of those needs a forge.
Architecture
developer: git push rad main v1.2.3 (tag signed by a repository delegate)
│
▼
radicle-node ──node events──▶ rad-actions container
├─ cib (radicle-ci-broker 0.32.1, unmodified): filter, queue, reports, job COBs
└─ rad-actions adapter (ci/adapter, ours):
· fetch the commit from the node's storage (depth 1)
· apply GitHub's `on:` filters
· run the workflow with act 0.2.89 (unmodified)
│ DOCKER_HOST = unix socket
▼
rad-actions-docker (dind, privileged, no TCP listener)
jobs run on its host network → localhost:5000 =
rad-actions-registry (registry:3, shares dind's network namespace)
▲ polled every minute over ci-internal
ipcr-gateway (ipcrd): import → IPFS → publish under IPNS
│
▼
anyone: docker pull ipcr.localhost:4767/ipns/metadec.eth/<repo>:1.2.3
- No mirror. The build reads the commit where it lives. Results go back into the repository as
job COBs (
xyz.radworks.job) signed by the node (see limitations: not for tags yet). - CI pages.
cibwrites static HTML reports; the adapter writes one plain-text log per run. The landing page serves both atipcr-forge-<domain>/ci/. - Handoff to IPFS is a pull. Jobs cannot reach
ipcr.localhost(the host's loopback), so they push to the staging registry andipcrdimports from it.ipcrdneeded no change for this.
Trust model
cibruns CI only for changes from a delegate of the repository (!AnyDelegate): release tags, the default branch and patches. A build can publish an image under the forge's IPNS name, so a build gets the trust of a release. Patches from non-delegates are never run.- The node's key (which signs job COBs) never enters a job: jobs get a self-contained checkout.
- The adapter refuses private repositories, because run logs are public.
- To run outsiders' patches safely, they would need a separate daemon with no route to the staging registry. Not built.
Workflow contract
A repository opts in by committing a workflow under .github/workflows/ (.gitea/ and
.forgejo/ are also read). The template on the landing page:
on:
push:
tags: ['v*']
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ vars.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
vars.IMAGE=localhost:5000/<repository name>;vars.REGISTRY=localhost:5000. No secret.- Two lines differ from a GitHub-hosted workflow:
context: .anddriver-opts: network=host(both explained below). github.repositoryisrad/<name>;RADICLE_RIDis set as an env var and a var.
Findings
Each of these silently produced the wrong result rather than an error.
act
- act does not apply
on:branch/tag filters. A workflow withon: push: tags: ['v*']ran for a push tomain. GitHub and Gitea filter server-side; the adapter does it here (filter.go, GitHub's pattern syntax, tested). - Without a git remote,
github.repositoryisnektos/act; with a non-GitHub remote it is the whole URL. The adapter passes--env GITHUB_REPOSITORY=rad/<name>. actions/checkoutis skipped by act, which copies the working directory into the job. So the checkout must be a self-contained repository. Agit clone --sharedof storage left a.gitwhose alternates pointed outside the job (unable to normalize alternate object path). The adapter now fetches the one commit, depth 1, withuploadpack.allowAnySHA1InWanton the serving side — whatactions/checkoutdoes by default.- Jobs run on the daemon's host network (
--network host, act's default). That is what makeslocalhost:5000reach the staging registry. It is also why compose service names do not resolve inside jobs (getaddrinfo ENOTFOUND rad-actions): the adapter advertises its own IP address instead.
GitHub-isms in common actions
docker/metadata-actioncalls the GitHub REST API (GET /repos/{owner}/{repo}) and fails withBad credentialswithout it. The adapter serves that one call for the length of a run, from what the node knows, and setsGITHUB_API_URLto it (api.go).html_urlis the Radicle explorer page, so the image'sorg.opencontainers.image.sourcelabel points there.docker/build-push-actiondefaults to a Git context (https://github.com/<repo>.git#<ref>), which does not exist here. Hencecontext: ..- buildx's builder container does not share the job's network;
driver-opts: network=hostputs it on the daemon's, wherelocalhost:5000is the registry. Docker and BuildKit accept plain HTTP forlocalhostonly, which is why the registry shares the daemon's namespace.
radicle-ci-broker (cib) 0.32.1
- Tags arrive as pushes. A tag event reaches the adapter as
event_type: "push"with the tag's name inbranch(src/msg.rs), so the first tag build ran as ifv1.1.1were a branch. The adapter looks the name up in storage (refs/namespaces/*/refs/tags/<name>). !HasFilematches files only, never directories (it checksinto_blob()), despite its doc comment. A filter on.github/workflowstherefore never matched and nothing ran. It was dropped; the adapter answers "no workflow" in about a second.- No job COB for tag builds. The job is keyed by the event's tip, which for an annotated tag is
the tag object; creating it fails silently and the run ends with
NoJob. Branch builds get their COB. Upstream fix: peel the tag before creating the job. - Default log level is trace: every gossip message the node sees. The image runs
--log-level info. - cib must not start before the node's control socket exists. It restart-loops a few times on
first install, then settles (
restart: unless-stopped).
Radicle
- The node only reacts to what it receives from other nodes: pushes come from the developer's
own node. The forge node still needs
rad seed <rid> --scope allper repository (seeding policyblock). - Commit and tag signing need an ssh-agent holding the Radicle key.
Verified (wisera, 2026-10-04)
ipcr-hello (rad:zc9XLrmUrt2xSgTLcEYfKN8eZuxS), tag v1.1.2 pushed from a delegate's node:
| Step | Time |
|---|---|
| tag pushed | 12:54:21 |
| build finished (first run on the box: act's image pulled cold) | 13:01:06 |
| imported into IPFS and published under IPNS | ~13:02:30 |
| pulled on wisera, and on holyhorse (26 s) | after |
Later, with metadec.eth pointing at the forge's publisher name (see naming.md),
ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.1.2 resolved and pulled on wisera.
Open items
- Publish the images.
ghcr.io/yundera/rad-actions(workflowrad-actions.yml, tagrad-actions-v*) andghcr.io/yundera/ipcr:1.2.0are so far built locally only. - Job COBs for tags (finding 10): an upstream patch to cib.
- Patches from non-delegates: a second, registry-less daemon.
- Warm cache. The first build pulls
catthehacker/ubuntu:act-latest(~1.5 GB) and clones the actions it uses; later builds reuse both (ci/docker,ci/cache). - Path filters (
on.push.paths) are not applied: a workflow with onlypaths:runs on every matching event. - Store install. Tested as a hand install that mimics Maison (compose,
.env,.seed/), not yet through the store.