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.
This commit is contained in:
Worph
2026-10-04 18:13:11 +00:00
parent a8a3037cba
commit f82543f518
7 changed files with 509 additions and 462 deletions
+182 -275
View File
@@ -4,35 +4,41 @@ name: ipcr-forge
#
# git push rad main v1.2.3 (developer)
# → radicle-node / radicle-api source of truth, peer-to-peer
# → radicle-gitea-bridge copies repos that commit .gitea/workflows/
# → gitea + gitea-runner GitHub-style Actions build the image
# → gitea registry staging: gitea-<domain>/gitea_admin/<app>:1.2.3
# → ipcr-gateway follows Gitea's registry imports every tag into IPFS, publishes IPNS
# → rad-actions (cib + act) the node's own CI: GitHub-style workflows, run on the
# commit straight from the node's storage
# → rad-actions-registry staging: localhost:5000/<repo>:1.2.3 (internal only)
# → ipcr-gateway follows that registry imports every tag into IPFS, publishes IPNS
# → ipcr-kubo / ipcr-registry
# docker pull ipcr.localhost:4767/ipns/<name>:1.2.3 (anyone)
#
# Radicle is the only place anything originates. There is no mirror, no second forge and no
# bridge: the CI broker reads the node's events and its storage, and writes each result back
# into the repository as a job COB signed by the node. The CI's pages (cib's reports and the run
# logs) are static files the landing page serves under /ci/.
#
# The tile opens ipcr-forge-<domain>: a static landing page (nginx) that explains the pipeline,
# links Radicle and Gitea, and lists every published image with the exact `image:` line to use
# (IPCR's published.json, served as /images.json). <name> is the IPNS key IPCR minted for the
# links Radicle and the CI runs, and lists every published image with the exact `image:` line to
# use (IPCR's published.json, served as /images.json). <name> is the IPNS key IPCR minted for the
# repository (k51…), not the repository's name, which is why the page lists it.
#
# Every service is copied verbatim from its standalone listing (Apps/Radicle, Apps/Gitea,
# Apps/Radicle-Gitea-Bridge, Apps/IPCR), so each keeps its own comments and rationale.md.
# Only the data folders move, one subfolder per part: $AppID/{radicle,gitea,bridge,ipcr}/.
# Radicle and IPCR services are copied verbatim from their standalone listings (Apps/Radicle,
# Apps/IPCR), so each keeps its own comments and rationale.md. Only the data folders move, one
# subfolder per part: $AppID/{radicle,ci,ipcr}/.
#
# Replaces those four apps; it cannot sit beside them. It uses the same container names,
# domains (radicle-, radicle-api-, gitea-) and host ports (8776, 4768, 127.0.0.1:4767).
# Replaces the Radicle and IPCR apps; it cannot sit beside them. It uses the same container
# names, domains (radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
#
# The handoff from Gitea's registry to IPFS is a pull, run by IPCR: the runner's jobs run in a
# nested Docker that cannot reach ipcr.localhost (the *host's* loopback), so CI never pushes to
# IPCR. Instead ipcr-gateway polls gitea:3000 over gitea-internal, with a read-only token the
# bridge-credentials step mints. Gitea does not know IPCR exists.
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the CI's own Docker daemon,
# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging
# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to
# every job, and ipcr-gateway polls it over ci-internal.
services:
# ═══ Landing page — the tile's web UI ══════════════════════════════════════════
# Static: the page, its icon, and IPCR's list of published images (read-only). It shows only
# links, documentation and image names that are public on IPFS anyway, so it is not behind
# the SSO gate; Gitea keeps its own login, Radicle is public by design.
# Static: the page, its icon, IPCR's list of published images and the CI's pages (read-only).
# It shows only links, documentation, image names that are public on IPFS anyway, and build
# logs of public repositories (the adapter does not run private ones), so it is not behind
# the SSO gate; Radicle is public by design.
ipcr-forge:
image: nginxinc/nginx-unprivileged:1.29.3-alpine
container_name: ipcr-forge
@@ -56,6 +62,8 @@ services:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/web/default.conf:/etc/nginx/conf.d/default.conf:ro
# published.json → /images.json. Read-only; the location in default.conf serves that file only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
# The CI broker's report pages and the run logs → /ci/. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/html:/srv/ci:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
@@ -202,143 +210,104 @@ services:
networks:
- radicle-internal
# ═══ Gitea — CI and the staging registry ═══════════════════════════════════════
# Verbatim from Apps/Gitea; only its data folder moves to $AppID/gitea/.
# ═══ CI — Radicle CI broker + act ═════════════════════════════════════════════════
# Gitea itself — git hosting, the Actions controller, and the OCI package
# registry, all in one process. SQLite: a personal forge never has the write
# concurrency that would justify a second container for Postgres.
gitea:
image: gitea/gitea:1.27.3-rootless
container_name: gitea
# cib (radicle-ci-broker) follows the node's events over its control socket, keeps the ones its
# filters allow (changes signed by a delegate, in a repository with a workflow folder — see
# ci/ci-broker.yaml in the IPCR repository), and runs the rad-actions adapter for each. The
# adapter checks the commit out of the node's storage and runs the matching GitHub-style
# workflows with act, against the Docker daemon below. cib writes HTML reports, and the result
# of every run back into the repository as a job COB signed by this node.
rad-actions:
image: ghcr.io/yundera/rad-actions:0.1.0
container_name: rad-actions
user: $PUID:$PGID
# Reaps act's and git's children: cib is not an init.
init: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
cpu_shares: 70
cpu_shares: 20
deploy:
resources:
limits:
memory: 1G
memory: 512M
environment:
TZ: $TZ
# Every URL Gitea hands out — clone URLs, the registry host, and the
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
# it has to be the address that works from outside as well as inside.
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
GITEA__server__HTTP_PORT: "3000"
GITEA__server__PROTOCOL: http
# The address Gitea's own CLI uses to reach the running server's internal
# API. It defaults to localhost, which is right for the server itself and
# useless from the init container that runs
# `gitea actions generate-runner-token`. It has to be set here rather than
# there: the CLI reads it out of the shared app.ini, and only this
# container's entrypoint renders environment into app.ini.
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
# Git over SSH would need a published host port of its own. Everything
# here — clone, push, CI, registry — goes over HTTPS instead.
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
# Skips the first-run install wizard: every value it would ask for is set
# here, and the admin account is created by an init step below.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__actions__ENABLED: "true"
# Where a bare `uses: actions/checkout@v4` is resolved from.
GITEA__actions__DEFAULT_ACTIONS_URL: github
GITEA__packages__ENABLED: "true"
# Lets a pull mirror name a repository on this server's own Docker
# network — a Radicle node's git endpoint, for instance. See rationale.md.
GITEA__migrations__ALLOW_LOCALNETWORKS: "true"
# How often a pull mirror is re-fetched. The sync emits the same push
# event a real push would, so this interval is also how quickly CI
# reacts to a commit that arrives from the mirrored source.
GITEA__mirror__DEFAULT_INTERVAL: 10m
GITEA__mirror__MIN_INTERVAL: 10m
# The daemon jobs run in. Its socket is the only thing shared with it.
DOCKER_HOST: unix:///run/dind/docker.sock
# The images act runs a `runs-on:` label in. act's own medium images (Ubuntu with Node and
# the Docker CLI), the ones act users run on their laptops.
RAD_ACTIONS_PLATFORMS: ubuntu-latest=catthehacker/ubuntu:act-latest,ubuntu-24.04=catthehacker/ubuntu:act-24.04,ubuntu-22.04=catthehacker/ubuntu:act-22.04
# `vars` every workflow sees. IMAGE = IMAGE_PREFIX/<repository name>, the name IPCR imports.
RAD_ACTIONS_VARS: REGISTRY=localhost:5000,IMAGE_PREFIX=localhost:5000
# cib's pages link each run to its log there (served by ipcr-forge).
RAD_ACTIONS_LOG_URL: /ci/logs/
# The repository's web page, as the adapter's GitHub API stand-in reports it (html_url, which
# docker/metadata-action turns into the image's org.opencontainers.image.source label).
RAD_ACTIONS_REPO_URL: https://radicle-${APP_DOMAIN}/nodes/radicle-api-${APP_DOMAIN}/{rid}
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/data:/var/lib/gitea
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/config:/etc/gitea
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
expose:
- 3000
labels:
caddy_0: gitea-${APP_DOMAIN}
caddy_0.import: gateway_tls
caddy_0.reverse_proxy: "{{upstreams 3000}}"
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
caddy_1.import: gateway_tls
caddy_1.reverse_proxy: "{{upstreams 3000}}"
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
caddy_2.reverse_proxy: "{{upstreams 3000}}"
# The node's home: its control socket (events), its storage (checkouts, job COBs) and its
# key, which signs the job COBs. The same mount radicle-api has.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
# cib's database, its pages and the run logs, act's action cache, and scratch checkouts.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/state:/ci/state
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/html:/ci/html
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/cache:/ci/cache
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/home:/ci/home
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/work:/ci/work
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
depends_on:
- radicle-node
- rad-actions-docker
networks:
- pcs
- gitea-internal
# Egress only: act fetches actions (actions/checkout, docker/*) from GitHub.
- ci-internal
# The CI runner: act_runner with its own Docker daemon inside it (dind), so
# workflow jobs and the images they build never touch the host's daemon.
# See rationale.md for what `privileged` buys and what it costs.
gitea-runner:
image: gitea/act_runner:0.6.1-dind
container_name: gitea-runner
restart: unless-stopped
# The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the
# host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener:
# its only API is a unix socket in a folder shared with rad-actions, owned by $PGID.
rad-actions-docker:
image: docker:29.1-dind
container_name: rad-actions-docker
privileged: true
restart: unless-stopped
cpu_shares: 20
command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"]
deploy:
resources:
limits:
memory: 2G
environment:
TZ: $TZ
# This address is not just how the runner daemon reaches Gitea — it is
# handed to every job as GITHUB_SERVER_URL, and a job runs inside the
# runner's *nested* daemon, on a network where `gitea` does not resolve.
# So it has to be an address that works from the outside: checkout and
# `docker login` from a workflow both go out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: gitea-runner
# Registration-time labels. runner-config/config.yaml repeats them, and
# that copy wins on every start, so an installed runner follows store updates.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,docker:docker://docker:28.5-cli
CONFIG_FILE: /config/config.yaml
# DOCKER_TLS_CERTDIR is deliberately left at the image's default. Emptying
# it does not turn the nested daemon's TCP listener off — it turns the
# listener's TLS off, and 2375 then answers unauthenticated to anything
# that can reach this container.
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
DOCKER_TLS_CERTDIR: ""
volumes:
# .runner (the registration this runner is identified by) and the
# registration token the init step leaves for it.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
# Replaces the image's own start-up wait — see the comments in the
# script. It is declared under x-compose-app.files rather than left to
# the seed tree, because it has to arrive executable.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config/act_runner-run:/etc/s6/act_runner/run:ro
# The inner daemon's image store. Without this every restart re-pulls
# every base image a workflow uses.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-docker:/var/lib/docker
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
# The daemon's image store. Without it every restart re-pulls every job image.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker
healthcheck:
test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- gitea-internal
- ci-internal
# ═══ Radicle → Gitea bridge ════════════════════════════════════════════════════
# Verbatim from Apps/Radicle-Gitea-Bridge; only its data folder moves to $AppID/bridge/.
radicle-gitea-bridge:
image: ghcr.io/worph/radicle-gitea-bridge:1.0.0
container_name: radicle-gitea-bridge
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
# every job (act runs jobs on the host network, which is this namespace), so a plain
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
# plain HTTP to localhost only. ipcr-gateway reaches it as rad-actions-docker:5000. Nothing
# outside ci-internal can.
rad-actions-registry:
image: registry:3.0.0
container_name: rad-actions-registry
user: $PUID:$PGID
# Reads two HTTP APIs and runs git. Needs no capability at all.
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["bridge"]
network_mode: service:rad-actions-docker
cpu_shares: 20
deploy:
resources:
@@ -346,31 +315,14 @@ services:
memory: 128M
environment:
TZ: $TZ
# git wants a home; the image's is root's and this runs as $PUID.
HOME: /tmp
RAD_API: http://radicle-api:8080
GITEA_URL: http://gitea:3000
# The Gitea account that owns the copies — the one the Gitea app creates.
GITEA_OWNER: gitea_admin
GITEA_TOKEN_FILE: /secrets/gitea-token
# A repository opts in by containing this path at its head.
OPT_IN_PATH: .gitea/workflows
# Seconds between passes. Releases are not latency-sensitive.
INTERVAL: "30"
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
REGISTRY_STORAGE_DELETE_ENABLED: "true"
OTEL_TRACES_EXPORTER: none
volumes:
# One bare repository per Radicle repository: a staging copy between fetch and
# push, so a push only sends what changed. Disposable: rebuilt if deleted.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/cache:/cache
# The Gitea token the init step mints. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
healthcheck:
test: ["CMD-SHELL", "test -s /secrets/gitea-token && curl -fsS -o /dev/null http://radicle-api:8080/api/v1 && curl -fsS -o /dev/null http://gitea:3000/api/healthz"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
networks:
- pcs
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
depends_on:
- rad-actions-docker
# ═══ IPCR — the IPFS container registry ════════════════════════════════════════
# Verbatim from Apps/IPCR; only its data folder moves to $AppID/ipcr/.
@@ -379,18 +331,13 @@ services:
# ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/<cid> through to the stock nerdctl
# registry below, resolves /ipns/<name>:<tag> itself, and pins every image it serves.
ipcr-gateway:
image: ghcr.io/yundera/ipcr:1.1.0
image: ghcr.io/yundera/ipcr:1.2.0
container_name: ipcr-gateway
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
# bind below), which is root-owned. See rationale.md.
user: "0:0"
# Root needs no capabilities to write root-owned files it owns; drop them all.
cap_drop: [ALL]
# Forge only: one back, read-only. The import token below is written by bridge-credentials as
# $PUID, mode 0600, in a 0700 folder; a root with no capabilities is bound by those modes like
# anyone else (verified: "permission denied"). DAC_READ_SEARCH bypasses read and search checks
# only, never write, and only on what this container mounts. See rationale.md.
cap_add: [DAC_READ_SEARCH]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
command: ["gateway"]
@@ -405,21 +352,20 @@ services:
# IPCR_IMPORT_REPOS) is copied into IPFS and published as /ipns/<owner-repo>:<tag>;
# a tag is re-imported when it moves. Empty = off. IPCR_IMPORT_AUTH is user:token,
# a read-only (read:package) token is enough.
# Forge: watch this app's own Gitea, over gitea-internal (not through Caddy).
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://gitea:3000}
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://rad-actions-docker:5000}
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
# Forge: a read:package token the bridge-credentials step mints. Re-read every poll
# and preferred over IMPORT_AUTH, so it may appear after the gateway is up.
IMPORT_AUTH_FILE: /data/secrets/registry-auth
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
# Forge: every repository this node builds is published under one IPNS key, "forge", as
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
IMPORT_INTERVAL: 60s
STATE_DIR: /data/state
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls
# What the watcher already imported (tag → manifest digest).
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state
# Forge only: the credential above. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/secrets:/data/secrets:ro
# The host's Docker trust store, so the CA above can be dropped into it: no daemon
# reload, no daemon.json edit. The parent is mounted because Compose cannot create a
# bind source whose name contains a colon (ipcr.localhost:4767). ipcrd writes exactly
@@ -439,8 +385,8 @@ services:
start_period: 10s
networks:
- ipcr-internal
# Forge only: reaches gitea:3000 for the import poller above.
- gitea-internal
# Forge only: reaches the staging registry for the import poller above.
- ci-internal
cpu_shares: 50
deploy:
resources:
@@ -450,7 +396,7 @@ services:
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry:
image: ghcr.io/yundera/ipcr:1.1.0
image: ghcr.io/yundera/ipcr:1.2.0
container_name: ipcr-registry
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
# without a rootless containerd — which `ipfs registry serve` never uses. The service has
@@ -531,8 +477,8 @@ networks:
# From Apps/Radicle: egress only, for the node.
radicle-internal:
driver: bridge
# From Apps/Gitea: gitea ↔ runner, nothing else.
gitea-internal:
# CI: rad-actions ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
ci-internal:
driver: bridge
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
ipcr-internal:
@@ -554,77 +500,90 @@ x-casaos:
title:
en_us: IPCR Forge
tagline:
en_us: A decentralised forge in one install — Radicle, Gitea Actions and IPFS images.
fr_fr: Une forge décentralisée en une installation — Radicle, Gitea Actions et images IPFS.
en_us: A decentralised forge in one install — Radicle, GitHub-style CI and IPFS images.
fr_fr: Une forge décentralisée en une installation — Radicle, CI au format GitHub et images IPFS.
description:
en_us: |
IPCR Forge is the full path from code to running container, without a central service:
• **Radicle** holds your repositories on a peer-to-peer network. You push with `git push rad`.
• **Gitea Actions** builds them with GitHub-style workflows, triggered by a release tag.
• **Radicle CI** on this node builds them with GitHub-style workflows, triggered by a release tag.
• **IPCR** serves the resulting images from IPFS, so anyone can `docker pull` them by name.
A repository joins the pipeline by committing a workflow under `.gitea/workflows/`: there
is nothing to configure per project.
Radicle is the single source of truth: builds read the commit straight from this node, and
their results go back into the repository as Radicle jobs. There is no mirror to keep in sync.
Its own page explains how to publish a project and lists every published image with the
line to put in a compose file.
A repository joins the pipeline by committing a workflow under `.github/workflows/`, as on
GitHub: there is nothing to configure per project.
This app bundles the Radicle, Gitea, Radicle Gitea Bridge and IPCR apps. Install it instead
of them, not beside them.
Its own page explains how to publish a project, links every build and its log, and lists
every published image with the line to put in a compose file.
This app bundles the Radicle and IPCR apps. Install it instead of them, not beside them.
fr_fr: |
IPCR Forge couvre tout le chemin du code au conteneur en production, sans service central :
• **Radicle** garde vos dépôts sur un réseau pair-à-pair. Vous poussez avec `git push rad`.
• **Gitea Actions** les construit avec des workflows au format GitHub, déclenchés par un tag.
• **Radicle CI** sur ce nœud les construit avec des workflows au format GitHub, déclenchés par un tag.
• **IPCR** sert les images produites depuis IPFS : n'importe qui peut les `docker pull`.
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.gitea/workflows/` : rien à
configurer par projet.
Radicle est l'unique source de vérité : les builds lisent le commit directement sur ce nœud,
et leurs résultats retournent dans le dépôt sous forme de jobs Radicle. Aucun miroir à
synchroniser.
Sa page explique comment publier un projet et liste chaque image publiée avec la ligne à
mettre dans un fichier compose.
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.github/workflows/`, comme sur
GitHub : rien à configurer par projet.
Cette application regroupe les applications Radicle, Gitea, Radicle Gitea Bridge et IPCR.
Installez-la à leur place, pas à côté.
Sa page explique comment publier un projet, donne accès à chaque build et à son journal, et
liste chaque image publiée avec la ligne à mettre dans un fichier compose.
Cette application regroupe les applications Radicle et IPCR. Installez-la à leur place, pas à
côté.
tips:
before_install:
en_us: |
**Replaces four apps.** Uninstall Radicle, Gitea, Radicle Gitea Bridge and IPCR first:
this app uses the same addresses and ports.
**Replaces two apps.** Uninstall Radicle and IPCR first: this app uses the same addresses
and ports.
| Address | What |
| --- | --- |
| `https://ipcr-forge-$APP_DOMAIN` | This app: how to publish, and every published image |
| `https://ipcr-forge-$APP_DOMAIN` | This app: how to publish, every published image |
| `https://ipcr-forge-$APP_DOMAIN/ci/` | CI runs and their logs (public) |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `https://gitea-$APP_DOMAIN` | Gitea: builds, logs, staging registry (`gitea_admin`) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
**Ports.** TCP 8776 (Radicle peers) and 4768 TCP+UDP (IPFS peers) are published.
**Images reach IPFS on their own.** IPCR follows this app's Gitea registry: about a
minute after a build pushes `gitea_admin/<repo>:<tag>`, it can be pulled as
**Who can build.** Only changes signed by one of a repository's delegates trigger a build:
release tags, the default branch and their patches. Builds run in their own Docker daemon.
**Images reach IPFS on their own.** A workflow pushes to `vars.IMAGE` (this node's
staging registry); about a minute later the image can be pulled as
`ipcr.localhost:4767/ipns/<name>:<tag>`, where `<name>` is the IPNS name (`k51…`) IPCR
creates for the repository. The app's page lists each one with the line to copy.
fr_fr: |
**Remplace quatre applications.** Désinstallez d'abord Radicle, Gitea, Radicle Gitea
Bridge et IPCR : cette application utilise les mêmes adresses et ports.
**Remplace deux applications.** Désinstallez d'abord Radicle et IPCR : cette application
utilise les mêmes adresses et ports.
| Adresse | Quoi |
| --- | --- |
| `https://ipcr-forge-$APP_DOMAIN` | Cette application : publier, et toutes les images publiées |
| `https://ipcr-forge-$APP_DOMAIN` | Cette application : publier, toutes les images publiées |
| `https://ipcr-forge-$APP_DOMAIN/ci/` | Builds et leurs journaux (public) |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `https://gitea-$APP_DOMAIN` | Gitea : builds, journaux, registre de transit (`gitea_admin`) |
| `ipcr.localhost:4767` | Registre IPCR, pour le Docker de ce serveur uniquement |
**Ports.** TCP 8776 (pairs Radicle) et 4768 TCP+UDP (pairs IPFS) sont publiés.
**Les images arrivent sur IPFS d'elles-mêmes.** IPCR suit le registre Gitea de cette
application : environ une minute après qu'un build a poussé `gitea_admin/<dépôt>:<tag>`,
elle se tire avec `ipcr.localhost:4767/ipns/<nom>:<tag>`, où `<nom>` est le nom IPNS
(`k51…`) qu'IPCR crée pour le dépôt. La page de l'application les liste, prêts à copier.
**Qui peut lancer un build.** Seuls les changements signés par un délégué du dépôt
déclenchent un build : tags de version, branche par défaut et leurs patches. Les builds
tournent dans leur propre démon Docker.
**Les images arrivent sur IPFS d'elles-mêmes.** Un workflow pousse vers `vars.IMAGE`
(le registre de transit de ce nœud) ; environ une minute plus tard, l'image se tire avec
`ipcr.localhost:4767/ipns/<nom>:<tag>`, où `<nom>` est le nom IPNS (`k51…`) qu'IPCR crée
pour le dépôt. La page de l'application les liste, prêts à copier.
index: /
webui_port: 8080
store_app_id: ipcr-forge
@@ -646,29 +605,35 @@ x-compose-app:
- path: /DATA/AppData/$AppID/radicle/caddy
user: $PUID
group: $PGID
# ── Gitea ──
- path: /DATA/AppData/$AppID/gitea/data
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/config
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/runner
# ── CI ──
- path: /DATA/AppData/$AppID/ci/state
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/gitea/runner-config
- path: /DATA/AppData/$AppID/ci/html/logs
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/gitea/runner-docker
- path: /DATA/AppData/$AppID/ci/cache
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/ci/home
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/ci/work
user: $PUID
group: $PGID
mode: "0700"
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
- path: /DATA/AppData/$AppID/ci/socket
user: $PUID
group: $PGID
mode: "0750"
- path: /DATA/AppData/$AppID/ci/docker
user: $PUID
group: $PGID
mode: "0710"
# ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/cache
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/bridge/secrets
- path: /DATA/AppData/$AppID/ci/registry
user: $PUID
group: $PGID
mode: "0700"
@@ -680,11 +645,6 @@ x-compose-app:
- path: /DATA/AppData/$AppID/ipcr/init.d
user: $PUID
group: $PGID
# Written by bridge-credentials as $PUID, read by ipcr-gateway (root).
- path: /DATA/AppData/$AppID/ipcr/secrets
user: $PUID
group: $PGID
mode: "0700"
files:
# The landing page follows store updates: re-rendered on every up. None of the three holds a
# dollar-brace sequence, so rendering cannot fail on an unresolved variable.
@@ -709,17 +669,12 @@ x-compose-app:
from: radicle/caddy/Caddyfile.tmpl
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/gitea/runner-config/act_runner-run
from: gitea/runner-config/act_runner-run
ensure: always
mode: "0755"
- path: /DATA/AppData/$AppID/ipcr/init.d/01-ipcr-node.sh
from: ipcr/init.d/01-ipcr-node.sh
ensure: always
mode: "0755"
init:
# In order: Radicle's identity before its node starts, then Gitea's admin, then the
# runner token and the bridge's credentials, which both need that admin to exist.
# Radicle's identity before its node starts. The CI needs nothing: it uses the node's.
- name: radicle-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
@@ -727,53 +682,5 @@ x-compose-app:
- /DATA/AppData/$AppID/radicle/home:/radicle-home
command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"]
when: absent:/DATA/AppData/$AppID/radicle/home/keys/radicle
- name: gitea-admin
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
gitea admin user create --admin --username gitea_admin \
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
--must-change-password=false \
|| echo "admin account already present, leaving it alone"
when: always
- name: gitea-runner-token
image: gitea/gitea:1.27.3-rootless
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
- /DATA/AppData/$AppID/gitea/runner:/runner
command:
- sh
- -c
- |
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
mv /runner/token.new /runner/token
chmod 600 /runner/token
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
- name: bridge-credentials
image: ghcr.io/worph/radicle-gitea-bridge:1.0.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/bridge/secrets:/secrets
- /DATA/AppData/$AppID/ipcr/secrets:/ipcr-secrets
# Also mints IPCR's read-only token for following Gitea's registry.
command:
- sh
- -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" IPCR_AUTH_FILE=/ipcr-secrets/registry-auth bridge-init
when: absent:/DATA/AppData/$AppID/bridge/secrets/gitea-token
webui-host: ipcr-forge-${domain}
webui-path: /
+85 -83
View File
@@ -1,9 +1,13 @@
# IPCR Forge — Rationale
IPCR Forge bundles four store apps (Radicle, Gitea, Radicle Gitea Bridge, IPCR) and a static
landing page. Their services are copied verbatim, so each part keeps the deviations its
standalone listing already argues. This document lists them, then covers only what the
bundle adds.
IPCR Forge bundles two store apps (Radicle, IPCR), the node's own CI (`rad-actions`) and a static
landing page. The Radicle and IPCR services are copied verbatim, so each keeps the deviations its
standalone listing already argues. This document lists them, then covers only what the bundle adds.
Earlier versions built with Gitea Actions, fed by a bridge that copied every Radicle repository
into Gitea. That made two places where state could start (Gitea held a second copy of each
repository, with its own PRs, issues and registry) and needed a glue service to keep them in step.
Gitea, its runner and the bridge are gone; the CI now runs on the Radicle node itself.
## What deviation / exception is being requested
@@ -12,98 +16,97 @@ bundle adds.
| From | Deviation |
| --- | --- |
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
| Gitea | `gitea-runner` is `privileged` (Docker-in-Docker); `ALLOW_LOCALNETWORKS`; admin account `gitea_admin` |
| Radicle Gitea Bridge | An install step signs in once with the default app password to mint scoped tokens |
| IPCR | `ipcr-gateway` and `ipcr-registry` run as root (no capabilities); `/etc/docker/certs.d` mounted; no authentication; ports `127.0.0.1:4767` and `4768/tcp+udp` |
**Added by the bundle:**
1. **A cross-app network link.** `ipcr-gateway` joins `gitea-internal`, the network the
standalone Gitea app reserves for Gitea and its runner.
2. **A cross-app credential.** The bridge's install step writes a token into IPCR's folder
(`ipcr/secrets/registry-auth`), which `ipcr-gateway` reads.
3. **A public landing page.** `ipcr-forge-<domain>` is not behind the SSO gate.
4. **It replaces four apps** and cannot be installed beside them: same container names,
domains and host ports.
5. **One added capability.** `ipcr-gateway` gets `cap_add: [DAC_READ_SEARCH]` on top of the
standalone listing's `cap_drop: [ALL]`.
1. **`rad-actions-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
2. **`rad-actions` mounts the Radicle node's home read-write**, including the node's key.
3. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry is.
4. **A public landing page, including the CI's pages.** `ipcr-forge-<domain>` and its `/ci/`
(run reports and logs) are not behind the SSO gate.
5. **It replaces two apps** and cannot be installed beside them: same container names, domains
and host ports.
## Why it is necessary
**1. The network link.** IPCR publishes images by *pulling* them from Gitea's registry: it
polls the registry and imports every new tag into IPFS. The pull has to come from IPCR's
side, because Gitea's build jobs run in the runner's nested Docker, which cannot reach
`ipcr.localhost` (that name is the host's loopback). In the standalone apps the gateway pulls
over Gitea's public HTTPS address. In the bundle both are on the same server, so it uses
`http://gitea:3000` over `gitea-internal` instead: no round trip through Caddy, and it keeps
working on a box with no public address. Gitea's registry hands out its bearer tokens at the
address the request came in on (`http://gitea:3000/v2/token`), so authentication stays
internal too.
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. The two ways to give
a CI job one are the host's socket (root on the PCS for every workflow) or a daemon of its own,
which needs `privileged`. The second confines a build to its own container and image store. The
rootless variant does not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`;
see Apps/Gitea/rationale.md, where it was tried first).
**2. The credential.** Gitea's registry requires sign-in (`REQUIRE_SIGNIN_VIEW`), so IPCR
needs a token. Gitea mints tokens only for a caller holding the account password, and the
bridge's install step is the one place that already does that. It mints one more token,
`ipcr-import`, with `read:package` only, and writes it as a file because an install step can
produce files but not environment variables. `ipcrd` reads `IMPORT_AUTH_FILE` at the start of
every poll, so the file may appear after the gateway has started.
**2. The node's home.** The CI broker (`cib`, upstream `radicle-ci-broker`) is a client of the
node: it subscribes to the node's events over the control socket in that folder, reads repository
storage to check out a commit, and writes each run's result back into the repository as a job COB,
which the node's key signs. This is the mount `radicle-api` already has. It is what removes the
mirror: the build reads the commit where it lives, so there is no copy to drift.
**3. The landing page.** It holds no data that isn't public already: links to Radicle (public
by design) and Gitea (behind its own login), documentation, and the list of images IPCR has
published, which are public on IPFS by definition. A login in front of it would protect
nothing and would stand between a developer and the `image:` line they came for.
**3. The network link.** IPCR publishes images by *pulling* them: it polls a registry and imports
every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach `ipcr.localhost` (that
name is the host's loopback), so a workflow pushes to a staging registry instead. That registry
shares the daemon's network namespace, so it is `localhost:5000` to every job — the only address
Docker and BuildKit accept plain HTTP for, so no TLS or credentials are involved. `ipcr-gateway`
reaches it as `rad-actions-docker:5000` over `ci-internal`.
**4. Replacing the four apps.** The bundle is the same services, not a different product.
Renaming every container, domain and port to coexist would break the defaults the bridge
and IPCR rely on (`radicle-api:8080`, `gitea:3000`) and the documented addresses users type,
for no use case: running two copies of the same forge on one server.
**4. The public pages.** The landing page holds links, documentation and image names that are
public on IPFS anyway. `/ci/` shows `cib`'s report pages and the run logs. Radicle repositories are
public by design, and the adapter refuses to run a private repository, so a log never shows code
that is not already public. A login in front would stand between a developer and their build log.
**5. `DAC_READ_SEARCH`.** The gateway runs as root (for the CA it drops into the host's Docker
trust store) with every capability dropped, and a root with no capabilities is bound by file
modes like any other user. The import token is written by the bridge's install step as `$PUID`,
mode `0600`, in a `0700` folder, so without the capability the gateway cannot open it (verified
on a fresh install: `open /data/secrets/registry-auth: permission denied`, then `401` from
Gitea). `DAC_READ_SEARCH` restores exactly the missing half: reading files and traversing
directories regardless of their modes.
**5. Replacing two apps.** The bundle is the same services, not a different product. Renaming every
container, domain and port to coexist would break the defaults the services rely on
(`radicle-api:8080`) and the documented addresses users type.
## Security mitigations in place
- **The added capability is read-only and narrow.** `DAC_READ_SEARCH` bypasses read and
directory-search checks only. It grants no write, ownership or privilege change, and reaches
only what the gateway mounts: its own TLS folder and state, the token (mounted read-only) and
the Docker trust store it already writes to.
- **The network link is narrow.** `gitea-internal` holds only `gitea`, `gitea-runner` and now
`ipcr-gateway`. The gateway's only listener is the read-only registry API. Kubo's
unauthenticated RPC API stays on `ipcr-internal`, which nothing from Gitea joins. Jobs run
in the runner's nested daemon, on a network where none of these names resolve.
- **The credential is read-only and contained.** `read:package` cannot push, delete or change
anything. The file is written as `$PUID` into a `0700` folder and mounted read-only into the
gateway. It is written then renamed, so a half-written token is never read. Re-running the
install step replaces the token in Gitea, which revokes the old one.
- **The admin password is used once and never stored.** It is passed to `bridge-init` on its
command line, as the Gitea app's own `create-admin` step does, and only the scoped tokens
are written to disk.
- **Only delegates can start a build.** `cib`'s filter (ci/ci-broker.yaml) runs CI only for changes
that come from a delegate of the repository (`AnyDelegate`): release tags, the default branch and
their patches. A stranger's patch is never run. This matters because a build can publish: what a
job pushes to the staging registry ends up on IPFS under the repository's IPNS name. A build
therefore gets exactly the trust of a release.
- **The privileged daemon has no network listener.** `dockerd` gets an explicit
`--host=unix:///run/dind/docker.sock` and `DOCKER_TLS_CERTDIR` is empty, so the entrypoint adds no
TCP socket. The unix socket sits in a folder shared only with `rad-actions`, group `$PGID`.
- **`rad-actions` itself runs unprivileged:** `$PUID`, every capability dropped,
`no-new-privileges`, 512 MB. Whoever controls it controls the daemon (that is the point of the
socket), so it accepts no input but the node's events and a workflow from a delegate.
- **The node's key never enters a job.** Jobs run in the nested daemon from a self-contained,
depth-1 checkout that the adapter fetched; the Radicle home is not mounted into them.
- **The staging registry is unreachable from outside.** No port, no Caddy route, on `ci-internal`
only (`rad-actions`, the daemon and `ipcr-gateway`). It runs as `$PUID` with no capabilities.
- **Bounded.** One run at a time (`concurrent_adapters: 1`), 60 minutes per run, 2 GB for the daemon.
- **The landing page cannot change anything.** `nginx-unprivileged` runs as `$PUID` with a
read-only root filesystem (a tmpfs `/tmp` only), no capabilities, `no-new-privileges` and a
32 MB limit. It mounts the page, its config and IPCR's `state/` read-only, and serves static
files only.
- **It publishes one file of IPCR's state, not the folder.** `state/` is mounted outside the
web root, and `default.conf` maps exactly one path to it:
`location = /images.json { alias /srv/ipcr-state/published.json; }`. The watcher's own
bookkeeping (`imported.json`, tag → digest) and anything else in `state/` answers 404.
- **Every inherited mitigation still applies,** because the services are unchanged. For
example, the privileged runner can still be stopped without affecting the rest of the forge.
read-only root filesystem, no capabilities, `no-new-privileges` and 32 MB. It mounts the page,
its config, IPCR's `state/` and the CI's `html/`, all read-only, and serves static files only.
From `state/` it maps exactly one file (`/images.json` → `published.json`).
- **The CI is severable.** Stopping `rad-actions` and `rad-actions-docker` removes the privileged
container and leaves a working Radicle node and IPCR registry.
- **Smaller than what it replaces.** Gone: an admin account with the default password, two minted
tokens, the `DAC_READ_SEARCH` capability IPCR needed to read one of them, and a second git server.
## Alternatives considered and rejected
| Alternative | Why not |
| --- | --- |
| Gitea pushes to IPCR (webhook, or a workflow step) | A workflow step cannot reach `ipcr.localhost`. A webhook would give IPCR a write endpoint on a shared network; a pull needs no inbound access at all. |
| Gateway pulls over the public `gitea-<domain>` address, as standalone | Works, but routes a same-box fetch out through Caddy and back, and fails on a box with no public address. |
| An admin-password credential for IPCR | Simpler, but gives the gateway full control of Gitea for a job that only reads packages. |
| `IMPORT_AUTH` as an environment variable | An install step cannot set one, so it would be a manual step after every install. |
| Renaming everything so the bundle can sit beside the four apps | Breaks the defaults and the documented addresses (see 4). |
| No landing page, Radicle explorer as the tile | Leaves newcomers without the pipeline explained or the IPNS name to pull, which IPCR mints and nobody can guess. |
| Keep Gitea, fed by a bridge (previous version) | Two sources of state (a second copy of each repository, with its own PRs and registry) and a glue service to keep them in step. |
| Gitea as a strict read-only mirror | Still a second git server and a sync to babysit, for one thing it adds: a web view of builds, which `cib`'s pages now provide. |
| Gitea's `act_runner` in `exec` mode | Same engine as `act` (a fork), but built for Gitea; nektos `act` is what people run on their laptops. |
| `cib`'s native adapter, or Ambient | Neither runs GitHub-style workflows; Ambient needs KVM. |
| `ipcrd` accepting pushes itself | A push endpoint would be a second registry implementation. The staging registry plus IPCR's existing import is byte-identical to `nerdctl push` and needed no change. |
| Host Docker socket for jobs | Any workflow would get root on the host. |
| Run patches from anyone | Their build could publish under the repository's IPNS name. Needs a separate daemon with no registry first. |
## Known limitations
- **No job COB for tag builds.** cib 0.32.1 keys a job by the event's tip, which for an annotated
tag is the tag object rather than a commit, and creating that job fails (silently; the log only
shows `NoJob` when the run finishes). Branch and patch builds get their job COB. Tag builds still
appear in `/ci/` with their log. Upstream fix: peel the tag before creating the job.
- **Tags arrive as pushes.** cib sends a tag event as `event_type: push` with the tag's name in
`branch`. The adapter tells them apart by looking for that name under `refs/tags/` in storage.
- **`HasFile` matches files only**, not folders, so cib cannot pre-filter on a workflow folder; the
adapter answers "no workflow" itself.
## Data protection
@@ -111,13 +114,12 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| Folder | Holds |
| --- | --- |
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
| `gitea/` | repositories, SQLite database, `app.ini`, runner registration, the runner's image store |
| `bridge/` | `cache/` (disposable bare repositories), `secrets/gitea-token` (0600) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `secrets/registry-auth` |
| `radicle/` | node identity (key pair), seeded repositories (including job COBs), Caddy config |
| `ci/` | `cib`'s database (`state/`), report pages and run logs (`html/`), act's action cache, the daemon's image store (`docker/`), the staging registry (`registry/`, disposable) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
| `site/`, `web/` | the landing page and its nginx config, re-rendered on every start |
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities
that matter on the network (Radicle's node key, Kubo's node key and IPNS keys) survive
uninstall and reinstall, so published repositories and image names stay valid.
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
matter on the network (Radicle's node key, Kubo's node key and IPNS keys) survive uninstall and
reinstall, so published repositories and image names stay valid.
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the
# stock script gets wrong for this deployment.
#
# 1. The nested Docker daemon. Upstream waits for it through s6's readiness
# notification. On a cold start with a warm image store dockerd takes longer
# than s6-notifyoncheck's window, the notification never arrives, s6 marks
# the service permanently failed, and act_runner sits blocked in
# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past
# anyway. The runner shows "offline" in the forge for that whole time and
# picks up no jobs. Asking the daemon directly is the same wait without the
# guesswork.
#
# 2. The registration token. It is minted by an init step that runs after the
# stack is up, so on a first install it does not exist yet when this
# container starts — and the stock entrypoint reads the token file exactly
# once, before its retry loop, so a token that arrives late is never seen.
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
while ! docker info >/dev/null 2>&1; do
sleep 1
done
while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do
echo "waiting for the runner registration token"
sleep 2
done
exec run.sh
@@ -1,40 +0,0 @@
# act_runner configuration. Only the settings that differ from upstream's
# defaults are written out — `act_runner generate-config` prints the full set.
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). These are upstream
# act_runner defaults: Gitea's own job images, which carry node, bash, git
# and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub. The registration
# labels pointed at node:22-bookworm, which has no docker CLI.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "docker:docker://docker:28.5-cli"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the Docker daemon nested inside that same
# container — cannot route to. Left on, every `actions/cache` step fails
# noisily instead of being skipped.
enabled: false
container:
# Jobs run on the runner's own nested daemon, on its default bridge: they
# reach the internet, and they reach Gitea the way anything outside would,
# through its public address.
network: bridge
privileged: false
force_pull: false
+46 -31
View File
@@ -8,8 +8,9 @@
<!--
Plain file, deliberately not a Maison .tmpl: it contains no dollar-brace sequence, and every
address is derived in the browser from this page's own hostname (ipcr-forge-<domain> →
radicle-<domain>, gitea-<domain>), so the nip.io / sslip.io aliases work too.
Live data: /images.json (IPCR's published.json) and the Radicle API's node info.
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
Live data: /images.json (IPCR's published.json), /ci/ (the CI broker's report pages) and the
Radicle API's node info.
-->
<style>
:root {
@@ -78,11 +79,12 @@
</div>
</header>
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server, without a central
service. Code lives on Radicle, Gitea Actions builds it, and IPCR serves the image from IPFS.</p>
service. Code lives on Radicle, this node builds it with GitHub-style workflows, and IPCR serves
the image from IPFS.</p>
<div class="cards">
<a class="card" id="link-radicle" href="#"><b>Radicle</b><span>Browse repositories, issues and patches. Public.</span></a>
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Builds, logs and the staging registry. Sign in as <code>gitea_admin</code>.</span></a>
<a class="card" href="ci/"><b>CI runs</b><span>Every build this node ran, with its log and result. Public.</span></a>
<a class="card" href="#images"><b>Published images</b><span>What this server serves from IPFS, with the line to put in a compose file.</span></a>
</div>
@@ -90,17 +92,22 @@ service. Code lives on Radicle, Gitea Actions builds it, and IPCR serves the ima
<div class="pipe">
<div class="step"><b>1 · Push</b><small><code>git push rad</code> a <code>v*</code> tag to the Radicle network</small></div>
<div class="arrow">→</div>
<div class="step"><b>2 · Bridge</b><small>repos with <code>.gitea/workflows/</code> are copied into Gitea</small></div>
<div class="step"><b>2 · Trigger</b><small>this node receives the tag and the Radicle CI broker picks it up</small></div>
<div class="arrow">→</div>
<div class="step"><b>3 · Build</b><small>Gitea Actions builds the image into Gitea's registry</small></div>
<div class="step"><b>3 · Build</b><small><code>act</code> runs the repo's <code>.github/workflows/</code> and pushes the image</small></div>
<div class="arrow">→</div>
<div class="step"><b>4 · IPFS</b><small>IPCR imports each tag and publishes it under an IPNS name</small></div>
<div class="arrow">→</div>
<div class="step"><b>5 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
</div>
<p>Steps 2 to 4 are automatic and usually take a few minutes. The very first build on this server
takes longer (about 5 to 10 minutes) while the runner downloads its build images. A repository
needs no configuration beyond committing a workflow.</p>
takes longer (about 5 to 10 minutes) while it downloads its build images. A repository needs no
configuration beyond committing a workflow. There is no second copy of it anywhere: the build
reads the commit straight from this node. Builds of the default branch and of patches also
write their result back into the repository, signed by the node, as a Radicle job.</p>
<p class="note">Only changes signed by one of the repository's delegates are built: release tags,
the default branch and their patches. A build can publish an image, so it gets the trust of a
release.</p>
<h2 id="images">Published images</h2>
<div id="images-body"><p class="muted">Loading…</p></div>
@@ -124,10 +131,13 @@ rad init</code></pre>
It ends with <code>✗ Target not met: could not fetch from […]</code>. That is about the public
Radicle seeds, not this server, and can be ignored. The repository is in place once it shows
in the <a class="radicle-link" href="#">Radicle explorer</a>.</li>
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag. The
<code>REGISTRY_TOKEN</code> secret is already set for every repository. Commit it
(<code>git add .gitea &amp;&amp; git commit -m "Build the image"</code>).
<li><b>Add a workflow</b> at <code>.github/workflows/build.yml</code>, as on GitHub. This one
builds the <code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag.
<code>vars.IMAGE</code> is set for every repository (this node's staging registry plus the
repository's name); there is no secret to configure. Two lines differ from a GitHub-hosted
workflow: <code>context: .</code> (builds the checkout, not a GitHub URL) and buildx's
<code>network=host</code> (so its builder can reach the staging registry). Commit it
(<code>git add .github &amp;&amp; git commit -m "Build the image"</code>).
<pre><code>on:
push:
tags: ['v*']
@@ -136,32 +146,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Registry address
id: registry
run: echo "host=&#36;{GITHUB_SERVER_URL#https://}" &gt;&gt; "&#36;GITHUB_OUTPUT"
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ steps.registry.outputs.host }}/&#36;{{ github.repository }}
images: &#36;{{ vars.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: &#36;{{ steps.registry.outputs.host }}
username: &#36;{{ github.repository_owner }}
password: &#36;{{ secrets.REGISTRY_TOKEN }}
driver-opts: network=host
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ steps.meta.outputs.labels }}</code></pre></li>
<li><b>Release:</b>
<pre><code>git tag v1.0.0
<pre><code>git tag -a v1.0.0 -m v1.0.0
git push rad main v1.0.0</code></pre>
Follow the build in Gitea under <code>gitea_admin/&lt;repo&gt;</code> → Actions. A minute after
it finishes, the tag appears under <a href="#images">Published images</a>.</li>
Follow the build under <a href="ci/">CI runs</a>. A minute after it finishes, the tag appears
under <a href="#images">Published images</a>.</li>
</ol>
<h2 id="use">Use an image</h2>
@@ -173,9 +178,17 @@ server and on any server running IPCR:</p>
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
<p><code>/ipns/&lt;name&gt;:&lt;tag&gt;</code> is a name the publisher can move to a new build.
<code>/ipfs/&lt;cid&gt;</code> is the image's content address and can never change.</p>
<p class="note">For a readable name, add a DNS TXT record <code>_dnslink.example.com</code> =
<code>dnslink=/ipns/k51…</code> to a domain you own. The image is then
<code>ipcr.localhost:4767/ipns/example.com:1.0.0</code>.</p>
<p class="note">For a readable name, point a name you own at this forge's publisher name
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
<ul>
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
<code>ipns://<span class="publisher">k51…</span></code> (one transaction, once). Images are then
<code>ipcr.localhost:4767/ipns/example.eth/&lt;repo&gt;:1.0.0</code>.</li>
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
<code>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
<code>ipcr.localhost:4767/ipns/example.com/&lt;repo&gt;:1.0.0</code>.</li>
</ul>
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
<h2 id="install">Pull from another server</h2>
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
@@ -199,12 +212,12 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<tr><th>Address</th><th>What</th></tr>
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: builds, logs, staging registry</td></tr>
<tr><td><a href="ci/">ipcr-forge-…/ci/</a></td><td>CI runs and their logs (public)</td></tr>
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
</table></div>
<footer>IPCR Forge · Radicle · Gitea · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
<footer>IPCR Forge · Radicle · Radicle CI broker · act · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
</main>
<script>
@@ -222,10 +235,8 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
document.getElementById("host").textContent = base || host;
setLink("link-radicle", "radicle");
Array.prototype.forEach.call(document.querySelectorAll("a.radicle-link"), function (a) { a.href = url("radicle"); });
setLink("link-gitea", "gitea");
setLink("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true);
setLink("t-gitea", "gitea", true);
function el(tag, text, cls) {
var e = document.createElement(tag);
@@ -262,12 +273,16 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (pub) {
var reg = pub.registry || "ipcr.localhost:4767";
if (pub.publisher) {
document.getElementById("publisher-name").textContent = pub.publisher;
Array.prototype.forEach.call(document.querySelectorAll("span.publisher"), function (e) { e.textContent = pub.publisher; });
}
var repos = Object.keys(pub.images || {}).sort();
if (!repos.length) throw new Error("empty");
body.textContent = "";
repos.forEach(function (repo) {
var r = pub.images[repo];
body.appendChild(el("h3", repo.replace(/^gitea_admin\//, "")));
body.appendChild(el("h3", repo));
var wrap = el("div", null, "scroll");
var t = el("table");
var head = el("tr");
+11 -1
View File
@@ -1,4 +1,5 @@
# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json.
# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json and the CI broker's
# report pages (and the adapter's run logs) under /ci/.
# No nginx variables on purpose: this file is also safe to render as a Maison template.
server {
listen 8080;
@@ -12,6 +13,15 @@ server {
add_header Cache-Control "no-store";
}
# cib rewrites these pages as runs progress; logs are plain text.
location /ci/ {
alias /srv/ci/;
index index.html;
types { text/html html; text/css css; application/json json; text/plain log; }
charset utf-8;
add_header Cache-Control "no-cache";
}
# Only the page and its icon; nothing else under the state directory is reachable.
location / {
add_header Cache-Control "no-cache";
+185
View File
@@ -0,0 +1,185 @@
# 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).
## 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:
```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.
## 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](../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.
### GitHub-isms in common actions
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.
### radicle-ci-broker (cib) 0.32.1
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`).
### 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.
## 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.