IPCR Forge v3: Gitea as the source of truth, public repos mirrored to Radicle
Users found Radicle-as-source unfriendly (v1, v2). Gitea is now where people work; its Actions build images into the staging registry IPCR follows, as in v2. - bridge/: ipcr-forge-bridge (Go). Install step mints a public-only token, registers one system webhook and creates the ipcr-hello example; the service mirrors every public repo one way to Radicle (rad init, canonical tags rule, git push rad) and serves the forge's page. - Compose: gitea + act_runner on a separate dind; tile opens Gitea; tips link the forge page. - Removed the Radicle CI broker (ci/) and the nginx landing page.
This commit is contained in:
+138
-147
@@ -1,185 +1,176 @@
|
||||
# IPCR Forge — design notes
|
||||
|
||||
How the forge in [Apps/IPCR-Forge/](../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](../Apps/IPCR-Forge/rationale.md).
|
||||
is shaped this way, and what was learned building it. The listing's own security argument is in
|
||||
[rationale.md](../Apps/IPCR-Forge/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.
|
||||
A forge people actually want to use, whose releases do not depend on it staying online:
|
||||
|
||||
## Why Gitea was removed
|
||||
- **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there.
|
||||
Developers use plain git and a familiar web UI.
|
||||
- **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR
|
||||
can pull them from any peer that holds them.
|
||||
- **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network,
|
||||
so it can be cloned without this server.
|
||||
|
||||
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:
|
||||
## History: three versions
|
||||
|
||||
- **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.
|
||||
| Version | Source of truth | CI | Feedback |
|
||||
| --- | --- | --- | --- |
|
||||
| v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing |
|
||||
| v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` |
|
||||
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
|
||||
|
||||
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the
|
||||
CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and
|
||||
rewritten in Go ([bridge/](../bridge/)).
|
||||
|
||||
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply
|
||||
here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
|
||||
opened on the Radicle side are not read.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
developer: git push rad main v1.2.3 (tag signed by a repository delegate)
|
||||
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
|
||||
│
|
||||
▼
|
||||
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
|
||||
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge)
|
||||
│ · git fetch from Gitea
|
||||
│ Actions · rad init (first time)
|
||||
▼ · git push rad (signed by the node)
|
||||
forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼
|
||||
jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network
|
||||
forge-registry (registry:3, shares dind's network namespace)
|
||||
▲ polled every minute over ci-internal
|
||||
ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key
|
||||
│
|
||||
▼
|
||||
anyone: docker pull ipcr.localhost:4767/ipns/metadec.eth/<repo>:1.2.3
|
||||
anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner>/<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.
|
||||
The two legs do not depend on each other: a repository can be built without being mirrored
|
||||
(private repositories), and mirrored without being built (no workflow).
|
||||
|
||||
## Trust model
|
||||
## The bridge
|
||||
|
||||
- `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.
|
||||
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`.
|
||||
|
||||
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
|
||||
`gitea_admin` with the default app password and does four things:
|
||||
- mints the daemon's token, `public-only` + `read:repository`;
|
||||
- generates the webhook secret;
|
||||
- creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`),
|
||||
matched by URL so it is never duplicated;
|
||||
- creates the example `gitea_admin/ipcr-hello`.
|
||||
- **`serve`**:
|
||||
- `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the
|
||||
repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds.
|
||||
- One worker, with a coalescing queue: a burst of deliveries is one sync.
|
||||
- A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames
|
||||
and visibility changes, plus any lost delivery.
|
||||
- `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future
|
||||
admin pages, such as Radicle settings.
|
||||
|
||||
### Mirror semantics
|
||||
|
||||
| Gitea | Radicle |
|
||||
| --- | --- |
|
||||
| public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical |
|
||||
| push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync <rid> --announce` |
|
||||
| tag created (git or web UI) | same; becomes canonical `refs/tags/<tag>` |
|
||||
| tag deleted | removed from the node's namespace; **the canonical `refs/tags/<tag>` stays** (see findings) |
|
||||
| default branch changed | `rad id update … defaultBranch` |
|
||||
| rename / transfer | same RID (state is keyed by Gitea's repository ID) |
|
||||
| made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be |
|
||||
| private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) |
|
||||
| empty | waits for its first push |
|
||||
|
||||
The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The
|
||||
code checks visibility again anyway.
|
||||
|
||||
## Trust
|
||||
|
||||
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
|
||||
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
|
||||
[naming.md](naming.md) is weaker for it.
|
||||
- **Anyone who can push to a repository can publish images.** A job can push any name to the
|
||||
unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS
|
||||
key, including images built from private repositories. Registration is off, so every user was
|
||||
created by the admin.
|
||||
- **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27.
|
||||
- The page, the tips and `rationale.md` say all of this plainly.
|
||||
- **Follow-up:** per-repository push credentials, with the bridge acting as a registry token
|
||||
server, would tie an image name to its repository.
|
||||
|
||||
## 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:
|
||||
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
||||
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
|
||||
|
||||
```yaml
|
||||
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.
|
||||
- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret.
|
||||
`docker/metadata-action` lowercases the name.
|
||||
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
|
||||
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
|
||||
|
||||
## Findings
|
||||
|
||||
Each of these silently produced the wrong result rather than an error.
|
||||
Carried over from v1 and v2; each one silently produced the wrong result rather than an error.
|
||||
|
||||
### act
|
||||
1. **Jobs must share the daemon's network namespace.** `localhost:5000` is the registry only on
|
||||
forge-docker's own network, and Docker/BuildKit accept plain HTTP for `localhost` only. The runner
|
||||
config sets `container.network: host`. As a consequence, compose service names do not resolve
|
||||
inside jobs, so `GITEA_INSTANCE_URL` is Gitea's public address.
|
||||
2. **`docker/build-push-action` defaults to a Git context** (`<server>/<repo>.git#<ref>`), which needs
|
||||
credentials 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.
|
||||
4. **act_runner's `run.sh` reads the registration token once** and gives up after 10 tries. The token
|
||||
is minted by an install step that runs after the stack is up, so the compose entrypoint waits for
|
||||
it first.
|
||||
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without
|
||||
one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it.
|
||||
The bridge adds the rule right after `rad init`.
|
||||
|
||||
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](../ci/adapter/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.
|
||||
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
||||
|
||||
### GitHub-isms in common actions
|
||||
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge
|
||||
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote.
|
||||
7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but
|
||||
`refs/tags/<tag>` keeps the last target. Tags are meant to be immutable, so this was left alone.
|
||||
8. **Gitea's default webhook allow-list (`external`) blocks the bridge.** It is on the server's
|
||||
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container.
|
||||
`private` would let any user's webhook reach every container on the shared network.
|
||||
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
|
||||
`--rid` flag.
|
||||
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with
|
||||
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
|
||||
network.
|
||||
|
||||
5. **`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](../ci/adapter/api.go)). `html_url`
|
||||
is the Radicle explorer page, so the image's `org.opencontainers.image.source` label points there.
|
||||
6. **`docker/build-push-action` defaults to a Git context** (`https://github.com/<repo>.git#<ref>`),
|
||||
which does not exist here. Hence `context: .`.
|
||||
7. **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.
|
||||
## Verified
|
||||
|
||||
### radicle-ci-broker (cib) 0.32.1
|
||||
Local integration (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker
|
||||
network):
|
||||
|
||||
8. **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>`).
|
||||
9. **`!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.
|
||||
10. **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.
|
||||
11. **Default log level is trace:** every gossip message the node sees. The image runs
|
||||
`--log-level info`.
|
||||
12. **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`).
|
||||
- `setup` run twice leaves exactly one system hook.
|
||||
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
|
||||
- A tag created through Gitea's API was canonical on Radicle within seconds, through the webhook.
|
||||
- A private repository never appeared.
|
||||
- A public repository made private was frozen.
|
||||
|
||||
### Radicle
|
||||
|
||||
13. **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`).
|
||||
14. **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](naming.md)),
|
||||
`ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.1.2` resolved and pulled on wisera.
|
||||
The full chain on a PCS (runner → staging → IPCR → pull from a second server) is in
|
||||
[Open items](#open-items) until it is run on a test box.
|
||||
|
||||
## 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.
|
||||
- **Run the full chain on a test PCS.** Check that the runner registers, that jobs see the daemon
|
||||
(`docker_host: ""` mounts its socket into each job), that a `v*` tag on `ipcr-hello` builds and
|
||||
imports, and that the image pulls from a second server.
|
||||
- **Publish the images**: `ghcr.io/yundera/ipcr:1.2.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.1.0`
|
||||
(workflows `image.yml` and `bridge.yml`, tag `bridge-v0.1.0`).
|
||||
- **Per-repository image names:** the bridge as a registry token server (see Trust).
|
||||
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
|
||||
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
|
||||
- **Store install:** test through the store, not only as a hand install.
|
||||
|
||||
Reference in New Issue
Block a user