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:
Worph
2026-10-04 18:34:26 +00:00
parent f82543f518
commit 6fe4c40cbe
24 changed files with 2275 additions and 594 deletions
+379 -243
View File
@@ -1,77 +1,88 @@
name: ipcr-forge
# IPCR Forge — the whole decentralised forge in one app:
# IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle:
#
# git push rad main v1.2.3 (developer)
# → radicle-node / radicle-api source of truth, peer-to-peer
# → 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
# git push origin main v1.2.3 (developer, plain git)
# → gitea the source of truth: repositories, PRs, issues, users
# → forge-runner (act_runner) Gitea Actions: .gitea/workflows/ for the tag
# → forge-registry staging: localhost:5000/<owner>/<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)
# docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>: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/.
# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
#
# The tile opens ipcr-forge-<domain>: a static landing page (nginx) that explains the pipeline,
# 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.
# The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the bridge: how to
# publish, the published images with the `image:` line to use, and the Radicle mirrors.
#
# 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}/.
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/.
#
# 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).
# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container
# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
#
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the CI's own Docker daemon,
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the forge'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, 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.
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge:
image: nginxinc/nginx-unprivileged:1.29.3-alpine
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
container_name: ipcr-forge
user: $PUID:$PGID
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
cpu_shares: 80
command: ["ipcr-forge-bridge", "serve"]
cpu_shares: 30
deploy:
resources:
limits:
memory: 32M
memory: 256M
environment:
TZ: $TZ
GITEA_URL: http://gitea:3000
GITEA_OWNER: gitea_admin
SECRETS_DIR: /secrets
STATE_DIR: /bridge
RAD_HOME: /radicle-home
IPCR_PUBLISHED: /srv/ipcr-state/published.json
RECONCILE: 10m
tmpfs:
# nginx-unprivileged keeps its pid file and temp paths here.
# HOME for git and rad.
- /tmp
volumes:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/site:/usr/share/nginx/html:ro
- ${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.
# The node's home: its storage (where repositories are created and pushed), its control
# socket (announces) and its key, which signs everything it publishes. The same mount
# radicle-api has.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
# The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
# published.json → /images.json. Read-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
depends_on:
- gitea
- radicle-node
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
test: ["CMD", "ipcr-forge-bridge", "health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
expose:
- 8080
- 8081
labels:
caddy_0: ipcr-forge-${APP_DOMAIN}
caddy_0.import: gateway_tls
@@ -84,8 +95,199 @@ services:
networks:
- pcs
# ═══ Radicle — the source of truth ═════════════════════════════════════════════
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/.
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
# 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
user: $PUID:$PGID
restart: unless-stopped
cpu_shares: 70
deploy:
resources:
limits:
memory: 1G
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
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would
# be a second place an image could be published from.
GITEA__packages__ENABLED: "false"
# Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by
# default, any public address. Not `private`: that would let any user's webhook reach every
# container on the shared network.
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
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}}"
networks:
- pcs
# ═══ CI — Gitea Actions ════════════════════════════════════════════════════════
# The runner: act_runner (plain, not its -dind variant) driving the separate daemon below over a
# unix socket. Runs unprivileged; whoever controls it controls that daemon, which is the point.
# Its config (seed/gitea/runner-config/config.yaml) puts jobs on the daemon's host network, where
# the staging registry is localhost:5000.
forge-runner:
image: gitea/act_runner:0.6.1
container_name: forge-runner
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
# The stock 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 wait for it (or for an
# existing registration) first.
entrypoint:
- /sbin/tini
- --
- sh
- -c
- |
until [ -s /data/token ] || [ -s /data/.runner ]; do echo "waiting for the runner registration token"; sleep 2; done
until [ -S /run/dind/docker.sock ]; do sleep 1; done
exec run.sh
cpu_shares: 20
deploy:
resources:
limits:
memory: 256M
environment:
TZ: $TZ
HOME: /data
DOCKER_HOST: unix:///run/dind/docker.sock
# Handed to every job as GITHUB_SERVER_URL, and jobs run on the daemon's network where
# `gitea` does not resolve: checkout goes out and back over HTTPS.
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
GITEA_RUNNER_NAME: forge-runner
# Registration-time labels; runner-config/config.yaml repeats them and wins on every start.
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
CONFIG_FILE: /config/config.yaml
volumes:
# .runner (this runner's registration) and the registration token the init step leaves.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
depends_on:
- gitea
- forge-docker
networks:
# Egress: it polls Gitea through its public address, like the jobs.
- ci-internal
# 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 forge-runner, owned by $PGID.
forge-docker:
image: docker:29.1-dind
container_name: forge-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
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
DOCKER_TLS_CERTDIR: ""
volumes:
- ${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:
- ci-internal
# 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 forge-docker:5000. Nothing
# outside ci-internal can.
forge-registry:
image: registry:3.0.0
container_name: forge-registry
user: $PUID:$PGID
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:forge-docker
cpu_shares: 20
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
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:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
depends_on:
- forge-docker
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
# creates and pushes repositories in this node's storage; the node replicates them.
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
# It is a pure client-side app — every request it makes goes from the visitor's
@@ -210,120 +412,6 @@ services:
networks:
- radicle-internal
# ═══ CI — Radicle CI broker + act ═════════════════════════════════════════════════
# 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: 20
deploy:
resources:
limits:
memory: 512M
environment:
TZ: $TZ
# 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:
# 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:
# Egress only: act fetches actions (actions/checkout, docker/*) from GitHub.
- ci-internal
# 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
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
DOCKER_TLS_CERTDIR: ""
volumes:
- ${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:
- ci-internal
# 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
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
network_mode: service:rad-actions-docker
cpu_shares: 20
deploy:
resources:
limits:
memory: 128M
environment:
TZ: $TZ
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:
- ${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/.
@@ -353,7 +441,7 @@ services:
# 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 the CI's staging registry, over ci-internal. Internal, so no credentials.
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://rad-actions-docker:5000}
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
IMPORT_REPOS: ${IPCR_IMPORT_REPOS:-}
# Forge: every repository this node builds is published under one IPNS key, "forge", as
@@ -477,7 +565,7 @@ networks:
# From Apps/Radicle: egress only, for the node.
radicle-internal:
driver: bridge
# CI: rad-actions ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
# CI: the runner ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
ci-internal:
driver: bridge
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
@@ -487,7 +575,7 @@ networks:
x-casaos:
architectures:
- amd64
main: ipcr-forge
main: gitea
author: Yundera
developer: Yundera
category: Developer
@@ -500,102 +588,127 @@ x-casaos:
title:
en_us: IPCR Forge
tagline:
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.
en_us: Gitea with Actions, images served from IPFS, code mirrored to Radicle.
fr_fr: Gitea avec Actions, images servies depuis IPFS, code répliqué sur Radicle.
description:
en_us: |
IPCR Forge is the full path from code to running container, without a central service:
IPCR Forge is a git forge whose releases do not depend on it staying online:
• **Radicle** holds your repositories on a peer-to-peer network. You push with `git push rad`.
• **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.
• **Gitea** is where you work: repositories, pull requests, issues, and GitHub-style Actions.
Push with plain `git`.
• **IPCR** publishes every image your workflows build on IPFS, so any server running IPCR
can `docker pull` it by name, from whichever peers hold it.
• **Radicle** receives a copy of every public repository, signed by this server, on its
peer-to-peer network: anyone can clone it, even when this server is down.
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.
A repository publishes images by committing a workflow under `.gitea/workflows/` that
pushes to `localhost:5000`. The install creates an example, `ipcr-hello`: create a tag on it
in Gitea and watch the whole chain run.
A repository joins the pipeline by committing a workflow under `.github/workflows/`, as on
GitHub: there is nothing to configure per project.
The forge's own page explains how to publish, lists every published image with the line to
put in a compose file, and every repository mirrored to Radicle.
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.
This app bundles the Gitea, 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 :
IPCR Forge est une forge git dont les versions publiées ne dépendent pas de sa disponibilité :
• **Radicle** garde vos dépôts sur un réseau pair-à-pair. Vous poussez avec `git push rad`.
• **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`.
• **Gitea** est l'endroit où l'on travaille : dépôts, pull requests, tickets, et Actions au
format GitHub. On pousse avec `git`, tout simplement.
• **IPCR** publie sur IPFS chaque image construite par vos workflows : tout serveur équipé
d'IPCR peut la `docker pull` par son nom, depuis les pairs qui la détiennent.
• **Radicle** reçoit une copie de chaque dépôt public, signée par ce serveur, sur son réseau
pair-à-pair : n'importe qui peut la cloner, même quand ce serveur est arrêté.
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.
Un dépôt publie des images en ajoutant un workflow sous `.gitea/workflows/` qui pousse vers
`localhost:5000`. L'installation crée un exemple, `ipcr-hello` : créez-y un tag dans Gitea et
regardez toute la chaîne s'exécuter.
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.github/workflows/`, comme sur
GitHub : rien à configurer par projet.
La page de la forge explique comment publier, liste chaque image publiée avec la ligne à
mettre dans un fichier compose, et chaque dépôt répliqué sur Radicle.
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é.
Cette application regroupe les applications Gitea, Radicle et IPCR. Installez-la à leur
place, pas à côté.
tips:
before_install:
en_us: |
**Replaces two apps.** Uninstall Radicle and IPCR first: this app uses the same addresses
and ports.
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
published images, the Radicle mirrors.
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
addresses and ports.
| Address | What |
| --- | --- |
| `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://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password |
| `https://ipcr-forge-$APP_DOMAIN` | The forge's page (public) |
| `https://radicle-$APP_DOMAIN` | Radicle explorer (public) |
| `https://radicle-api-$APP_DOMAIN` | Radicle API and read-only git (public) |
| `ipcr.localhost:4767` | IPCR registry, this server's Docker only |
**Ports.** TCP 8776 (Radicle peers) and 4768 TCP+UDP (IPFS peers) are published.
**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.
**Public repositories go to Radicle.** Every public Gitea repository is copied there, signed
by this server, within seconds of each push. That cannot be undone: making a repository
private later stops the copy, and what was published stays on the network. Private
repositories are never copied.
**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.
**Who can publish images.** Anyone who can push to a repository here: a build can push any
image name to the staging registry, and IPCR publishes what it finds there under this
forge's name. Images are public once on IPFS, even from a private repository. Read a
fork's workflow before approving its run.
fr_fr: |
**Remplace deux applications.** Désinstallez d'abord Radicle et IPCR : cette application
utilise les mêmes adresses et ports.
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
images publiées, les copies Radicle.
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
application utilise les mêmes adresses et ports.
| Adresse | Quoi |
| --- | --- |
| `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://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut |
| `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) |
| `https://radicle-$APP_DOMAIN` | Explorateur Radicle (public) |
| `https://radicle-api-$APP_DOMAIN` | API Radicle et git en lecture seule (public) |
| `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.
**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 dépôts publics partent sur Radicle.** Chaque dépôt public de Gitea y est copié, signé
par ce serveur, quelques secondes après chaque push. C'est irréversible : rendre un dépôt
privé ensuite arrête la copie, et ce qui a été publié reste sur le réseau. Les dépôts privés
ne sont jamais copiés.
**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.
**Qui peut publier des images.** Toute personne qui peut pousser sur un dépôt d'ici : un
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
index: /
webui_port: 8080
webui_port: 3000
store_app_id: ipcr-forge
x-compose-app:
schema_version: 2
folders:
# ── Landing page ──
- path: /DATA/AppData/$AppID/site
# ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/state
user: $PUID
group: $PGID
- path: /DATA/AppData/$AppID/web
- path: /DATA/AppData/$AppID/bridge/secrets
user: $PUID
group: $PGID
mode: "0700"
# ── 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
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/gitea/runner-config
user: $PUID
group: $PGID
# ── Radicle ──
@@ -605,25 +718,7 @@ x-compose-app:
- path: /DATA/AppData/$AppID/radicle/caddy
user: $PUID
group: $PGID
# ── CI ──
- path: /DATA/AppData/$AppID/ci/state
user: $PUID
group: $PGID
mode: "0700"
- path: /DATA/AppData/$AppID/ci/html/logs
user: $PUID
group: $PGID
- 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"
# ── CI daemon ──
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
- path: /DATA/AppData/$AppID/ci/socket
user: $PUID
@@ -646,21 +741,12 @@ x-compose-app:
user: $PUID
group: $PGID
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.
- path: /DATA/AppData/$AppID/site/index.html
from: site/index.html
# Each entry and its reason are in the standalone listing it comes from. All follow store
# updates (re-rendered on every up).
- path: /DATA/AppData/$AppID/gitea/runner-config/config.yaml
from: gitea/runner-config/config.yaml
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/site/icon.png
from: site/icon.png
ensure: always
mode: "0644"
- path: /DATA/AppData/$AppID/web/default.conf
from: web/default.conf
ensure: always
mode: "0644"
# Each entry and its reason are in the standalone listing it comes from.
- path: /DATA/AppData/$AppID/radicle/home/config.json
from: radicle/home/config.json.tmpl
ensure: always
@@ -674,7 +760,8 @@ x-compose-app:
ensure: always
mode: "0755"
init:
# Radicle's identity before its node starts. The CI needs nothing: it uses the node's.
# In order: Radicle's identity before its node starts, then Gitea's admin, then the runner
# token and the bridge's setup, which both need that admin to exist.
- name: radicle-identity
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
user: $PUID:$PGID
@@ -682,5 +769,54 @@ 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
webui-host: ipcr-forge-${domain}
- 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
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
# webhook, and the example repository. Idempotent, so on every start: it repairs the webhook.
# The admin password is used here only, never stored.
- name: forge-setup
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
user: $PUID:$PGID
phase: post_up
network: $APP_NET
volumes:
- /DATA/AppData/$AppID/bridge/secrets:/secrets
command:
- sh
- -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 ipcr-forge-bridge setup
when: always
webui-host: gitea-${domain}
webui-path: /
+121 -82
View File
@@ -1,112 +1,148 @@
# IPCR Forge — Rationale
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.
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
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.
Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only
Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work
with. See docs/forge.md.
## What deviation / exception is being requested
**Inherited, unchanged** (the full argument is in each listing's `rationale.md`):
**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`.
| From | Deviation |
| --- | --- |
| Gitea | An admin account (`gitea_admin`) created with the server's default app password |
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
| 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. **`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.
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
key. It publishes repositories signed by that key.
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
4. **An install step that uses the admin password** to mint the bridge's token and to register
that webhook.
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
is.
6. **A public page.** `ipcr-forge-<domain>` is not behind the SSO gate.
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
domains and host ports.
## Why it is necessary
**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).
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one
in two ways:
- the host's socket, which gives every workflow root on the PCS;
- a daemon of its own, which needs `privileged`.
**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.
The second keeps a build inside 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). The runner itself is a separate, unprivileged
container that reaches the daemon over a unix socket.
**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`.
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
node's storage, signing its refs with the node's key, and announcing them through the node's
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
**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.
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
container on the shared network.
**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.
**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an
administrator signing in. The step uses the password once per start, never stores it, and
leaves the running service holding nothing but a `public-only`, `read:repository` token. If the
password has been changed, the step keeps the existing token and webhook instead of failing.
**5. 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`, because 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 every job sees it as
`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS
or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`.
**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway,
and the list of repositories already public on Radicle. A login in front of it would stand between
a stranger and the instructions for pulling an image.
**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays
available without this server. Only public repositories go. A public Gitea repository is meant to
be read by anyone, and Radicle is one more place to read it.
**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming
every container, domain and port so they could coexist would break the defaults the services rely
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
## Security mitigations in place
- **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, 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.
**The bridge**
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
`read:repository`. Gitea hides every private or limited repository from it, and it can write
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
can at most cause a pointless sync. Port 8081 has no Caddy route.
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
`no-new-privileges`, 256 MB. One worker does one sync at a time.
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home.
**The CI daemon**
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`,
group `$PGID`.
- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in
the nested daemon, never on the host's.
- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits
on `ci-internal` only. It runs as `$PUID` with no capabilities.
- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon.
- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and
leaves a working Gitea, Radicle node and IPCR registry.
**Gitea**
- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access
waits for "Approve and run".
- **Registration is off.** Every account is created by the admin.
- **Who can publish is stated** in the tips and on the page. Write access to any repository means
being able to publish images under the forge's name. Images from private repositories become
public on IPFS.
**The page**
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
## Alternatives considered and rejected
| Alternative | Why not |
| --- | --- |
| 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. |
| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing |
| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` |
| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer |
| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs |
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
| Host Docker socket for jobs | Any workflow would get root on the host |
| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
## 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.
- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no
further sync). What already reached the network stays there.
- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own
namespace only.
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
delegate.
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
Per-repository credentials, issued by the bridge, are a planned follow-up.
## Data protection
@@ -114,12 +150,15 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| Folder | Holds |
| --- | --- |
| `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) |
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
| `bridge/` | `state/`: the Gitea → Radicle mapping and one bare mirror per repository (disposable); `secrets/` (0700): its Gitea token and the webhook secret |
| `ci/` | the daemon's socket, its 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.
matter on the network survive uninstall and reinstall, so published repositories and image names
stay valid:
- Radicle's node key;
- Kubo's node key and IPNS keys.
@@ -0,0 +1,42 @@
# act_runner configuration for IPCR Forge. Only the settings that differ from upstream's defaults
# are written out — `act_runner generate-config` prints the full set. From Apps/Gitea's, except
# where marked "Forge:".
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). 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.
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"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the separate Docker daemon — cannot route to.
# Left on, every `actions/cache` step fails noisily instead of being skipped.
enabled: false
container:
# Forge: jobs run on the daemon's host network, which is the forge-docker container's own
# namespace — where the staging registry listens as localhost:5000. Plain HTTP to localhost is
# the one thing Docker and BuildKit accept without TLS, so a workflow needs no login and no
# certificate. Jobs still reach the internet (actions, base images) and Gitea through its public
# address.
network: host
privileged: false
# Empty: the runner's own daemon (DOCKER_HOST) is mounted into each job, so `docker build`
# and buildx work in a job, against the same daemon (and the same localhost:5000).
docker_host: ""
force_pull: false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

-334
View File
@@ -1,334 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCR Forge</title>
<link rel="icon" href="icon.png">
<!--
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>, 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 {
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
header img { width: 64px; height: 64px; }
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
h2 { font-size: 21px; margin: 40px 0 12px; }
h3 { font-size: 16px; margin: 20px 0 6px; }
p { margin: 8px 0; }
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
a { color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
text-decoration: none; color: inherit; display: block; }
a.card:hover { border-color: var(--accent); }
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
.card span { color: var(--muted); font-size: 14px; }
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 12px; font-size: 14px; }
.step b { display: block; font-size: 15px; }
.step small { color: var(--muted); }
.arrow { align-self: center; color: var(--muted); }
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
overflow-x: auto; line-height: 1.5; }
pre code { background: none; padding: 0; }
ol { padding-left: 22px; }
li { margin: 6px 0; }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; overflow: hidden; font-size: 14px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
tr:last-child td { border-bottom: 0; }
td code { word-break: break-all; }
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
.muted { color: var(--muted); }
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
.scroll { overflow-x: auto; }
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<header>
<img src="icon.png" alt="">
<div>
<h1>IPCR Forge</h1>
<div class="muted" id="host"></div>
</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, 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" 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>
<h2 id="how">How it works</h2>
<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 · 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><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 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>
<h2 id="publish">Publish a project</h2>
<ol>
<li><b>Install Radicle</b> on your machine and create an identity: see
<a href="https://radicle.xyz/" target="_blank" rel="noopener">radicle.xyz</a>, then <code>rad auth</code>.
Start your own node, which carries your pushes: <code>rad node start</code>.</li>
<li><b>Connect to this server's node</b> so your pushes reach it:
<pre><code id="connect-cmd">rad node connect &lt;node-id&gt;@&lt;this server&gt;:8776</code></pre></li>
<li><b>Publish the repository.</b> It has to be a Git repository with at least one commit:
<pre><code>git init -b main
git add -A
git commit -m "First commit"
rad init</code></pre>
<code>rad init</code> prints the repository ID (<code>rad:z…</code>).</li>
<li><b>Let this node seed it.</b> The node only keeps repositories it is told to (seeding policy
<i>block</i>). In this server's Terminal app or over SSH:
<pre><code>docker exec radicle-node rad seed rad:z… --scope all</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>.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*']
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ 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: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ steps.meta.outputs.labels }}</code></pre></li>
<li><b>Release:</b>
<pre><code>git tag -a v1.0.0 -m v1.0.0
git push rad main v1.0.0</code></pre>
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>
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
server and on any server running IPCR:</p>
<pre><code>services:
myapp:
image: ipcr.localhost:4767/ipns/k51qzi5uqu5d…:1.0.0 # a tag: follows new builds of 1.0.0
# 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, 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,
from this server and from every other server that already pulled it.</p>
<h3>On a Yundera server</h3>
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
<h3>On any Linux server with Docker 29+</h3>
<pre><code>mkdir -p /DATA/AppData/ipcr &amp;&amp; cd /DATA/AppData/ipcr
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
install -d -m 0700 -o 1000 -g 1000 kubo &amp;&amp; install -d -o 1000 -g 1000 init.d
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
chmod 0755 init.d/01-ipcr-node.sh
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' &gt; .env
docker compose up -d</code></pre>
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
<h2 id="addresses">Addresses on this server</h2>
<div class="scroll"><table>
<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 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 · Radicle CI broker · act · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
</main>
<script>
(function () {
"use strict";
var host = location.hostname;
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
function setLink(id, prefix, text) {
var a = document.getElementById(id);
if (!a) return;
a.href = url(prefix);
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
}
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("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true);
function el(tag, text, cls) {
var e = document.createElement(tag);
if (text != null) e.textContent = text;
if (cls) e.className = cls;
return e;
}
function copyButton(text) {
var b = el("button", "Copy", "copy");
b.type = "button";
b.addEventListener("click", function () {
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
} else { window.prompt("Copy:", text); }
});
return b;
}
function cmpTags(a, b) {
if (a === "latest") return -1;
if (b === "latest") return 1;
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
var x = pa[i] || "", y = pb[i] || "";
var nx = parseInt(x, 10), ny = parseInt(y, 10);
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
if (x !== y) return x < y ? 1 : -1;
}
return 0;
}
var body = document.getElementById("images-body");
fetch("images.json", { cache: "no-store" })
.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));
var wrap = el("div", null, "scroll");
var t = el("table");
var head = el("tr");
["Tag", "Compose line", ""].forEach(function (h) { head.appendChild(el("th", h)); });
t.appendChild(head);
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
var info = r.tags[tag];
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tag));
if (info.at) td1.appendChild(el("div", info.at.replace("T", " ").replace("Z", " UTC"), "muted"));
var td2 = el("td"); td2.appendChild(el("code", line));
var pin = el("div", null, "muted");
pin.appendChild(document.createTextNode("this build only: "));
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
td2.appendChild(pin);
var td3 = el("td"); td3.appendChild(copyButton(line));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
t.appendChild(tr);
});
wrap.appendChild(t);
body.appendChild(wrap);
});
})
.catch(function () {
body.innerHTML = "";
body.appendChild(el("p", "Nothing published yet. Push a v* tag from a repository with a workflow (see below); its image appears here a few minutes later.", "muted"));
});
// The node's ID and dialable address, for `rad node connect`. The Radicle API is public and
// CORS-enabled (the explorer calls it from the browser). The address must be the node's own
// externalAddresses entry (radicle-<ip>.sslip.io:8776): the *-<domain> names are behind an
// HTTP proxy that does not carry port 8776.
if (base) {
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (node) {
var addrs = (node.config && node.config.externalAddresses) || [];
if (node.id && addrs.length) {
document.getElementById("connect-cmd").textContent =
"rad node connect " + node.id + "@" + addrs[0];
}
})
.catch(function () {});
}
})();
</script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
# 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;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
location = /images.json {
alias /srv/ipcr-state/published.json;
default_type application/json;
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";
}
}