Files
ipcr-forge/docs/forge.md
T
Worph f82543f518 IPCR Forge v2: Radicle-only with rad-actions
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.
2026-10-04 18:13:11 +00:00

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. cib writes static HTML reports; the adapter writes one plain-text log per run. The landing page serves both at ipcr-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 and ipcrd imports from it. ipcrd needed no change for this.

Trust model

  • cib runs 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: . and driver-opts: network=host (both explained below).
  • github.repository is rad/<name>; RADICLE_RID is set as an env var and a var.

Findings

Each of these silently produced the wrong result rather than an error.

act

  1. act does not apply on: branch/tag filters. A workflow with on: push: tags: ['v*'] ran for a push to main. GitHub and Gitea filter server-side; the adapter does it here (filter.go, GitHub's pattern syntax, tested).
  2. Without a git remote, github.repository is nektos/act; with a non-GitHub remote it is the whole URL. The adapter passes --env GITHUB_REPOSITORY=rad/<name>.
  3. actions/checkout is skipped by act, which copies the working directory into the job. So the checkout must be a self-contained repository. A git clone --shared of storage left a .git whose alternates pointed outside the job (unable to normalize alternate object path). The adapter now fetches the one commit, depth 1, with uploadpack.allowAnySHA1InWant on the serving side — what actions/checkout does by default.
  4. Jobs run on the daemon's host network (--network host, act's default). That is what makes localhost:5000 reach 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

  1. docker/metadata-action calls the GitHub REST API (GET /repos/{owner}/{repo}) and fails with Bad credentials without it. The adapter serves that one call for the length of a run, from what the node knows, and sets GITHUB_API_URL to it (api.go). html_url is the Radicle explorer page, so the image's org.opencontainers.image.source label points there.
  2. docker/build-push-action defaults to a Git context (https://github.com/<repo>.git#<ref>), which does not exist here. Hence context: ..
  3. buildx's builder container does not share the job's network; driver-opts: network=host puts it on the daemon's, where localhost:5000 is the registry. Docker and BuildKit accept plain HTTP for localhost only, which is why the registry shares the daemon's namespace.

radicle-ci-broker (cib) 0.32.1

  1. Tags arrive as pushes. A tag event reaches the adapter as event_type: "push" with the tag's name in branch (src/msg.rs), so the first tag build ran as if v1.1.1 were a branch. The adapter looks the name up in storage (refs/namespaces/*/refs/tags/<name>).
  2. !HasFile matches files only, never directories (it checks into_blob()), despite its doc comment. A filter on .github/workflows therefore never matched and nothing ran. It was dropped; the adapter answers "no workflow" in about a second.
  3. 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.
  4. Default log level is trace: every gossip message the node sees. The image runs --log-level info.
  5. 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

  1. 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 all per repository (seeding policy block).
  2. 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 (workflow rad-actions.yml, tag rad-actions-v*) and ghcr.io/yundera/ipcr:1.2.0 are 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 only paths: runs on every matching event.
  • Store install. Tested as a hand install that mimics Maison (compose, .env, .seed/), not yet through the store.