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
+61
View File
@@ -0,0 +1,61 @@
name: bridge
# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page.
on:
push:
branches: [main]
tags: ["bridge-v*"]
paths: ["bridge/**", ".github/workflows/bridge.yml"]
pull_request:
paths: ["bridge/**", ".github/workflows/bridge.yml"]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/yundera/ipcr-forge-bridge
jobs:
vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
working-directory: bridge
image:
needs: vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=match,pattern=bridge-v(.*),group=1
type=ref,event=branch
type=sha
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
# amd64 only.
- uses: docker/build-push-action@v6
with:
context: bridge
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=bridge
cache-to: type=gha,mode=max,scope=bridge
+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
-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";
}
}
+138 -147
View File
@@ -1,185 +1,176 @@
# IPCR Forge — design notes
How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it
is shaped this way, and what was learned building it (2026-10-04). The listing's own security
argument is in [rationale.md](../Apps/IPCR-Forge/rationale.md).
is shaped this way, and what was learned building it. The listing's own security argument is in
[rationale.md](../Apps/IPCR-Forge/rationale.md).
## Goal
A decentralised forge that works out of the box: **Radicle is the single source of truth**, builds
use GitHub-style workflows (the developer comfort worth keeping), and images are served from IPFS
through IPCR. Nothing in the chain is a second copy that can drift from Radicle.
A forge people actually want to use, whose releases do not depend on it staying online:
## Why Gitea was removed
- **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there.
Developers use plain git and a familiar web UI.
- **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR
can pull them from any peer that holds them.
- **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network,
so it can be cloned without this server.
The first version built with Gitea Actions. A bridge service copied every Radicle repository into
Gitea, Gitea's runner built it, and IPCR imported from Gitea's registry. It worked end to end, but:
## History: three versions
- **Two places where state could originate.** Gitea held a full copy of each repository, with its
own PRs, issues and registry. A copy that is only ever *derived* is a cache; one where things can
also *start* will desync.
- **A glue service** (the bridge) to keep the two in step, plus its credentials: an admin account,
two minted tokens, and a capability (`DAC_READ_SEARCH`) IPCR needed only to read one of them.
- What Gitea actually contributed was narrow: running Actions YAML, a web view of runs, and a
staging registry. None of those needs a forge.
| Version | Source of truth | CI | Feedback |
| --- | --- | --- | --- |
| v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing |
| v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` |
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the
CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and
rewritten in Go ([bridge/](../bridge/)).
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply
here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
opened on the Radicle side are not read.
## Architecture
```
developer: git push rad main v1.2.3 (tag signed by a repository delegate)
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
│
▼
radicle-node ──node events──▶ rad-actions container
├─ cib (radicle-ci-broker 0.32.1, unmodified): filter, queue, reports, job COBs
└─ rad-actions adapter (ci/adapter, ours):
· fetch the commit from the node's storage (depth 1)
· apply GitHub's `on:` filters
· run the workflow with act 0.2.89 (unmodified)
│ DOCKER_HOST = unix socket
▼
rad-actions-docker (dind, privileged, no TCP listener)
jobs run on its host network → localhost:5000 =
rad-actions-registry (registry:3, shares dind's network namespace)
▲ polled every minute over ci-internal
ipcr-gateway (ipcrd): import → IPFS → publish under IPNS
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge)
│ · git fetch from Gitea
│ Actions · rad init (first time)
▼ · git push rad (signed by the node)
forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼
jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network
forge-registry (registry:3, shares dind's network namespace)
▲ polled every minute over ci-internal
ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key
│
▼
anyone: docker pull ipcr.localhost:4767/ipns/metadec.eth/<repo>:1.2.3
anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner>/<repo>:1.2.3
```
- **No mirror.** The build reads the commit where it lives. Results go back into the repository as
job COBs (`xyz.radworks.job`) signed by the node (see limitations: not for tags yet).
- **CI pages.** `cib` writes static HTML reports; the adapter writes one plain-text log per run. The
landing page serves both at `ipcr-forge-<domain>/ci/`.
- **Handoff to IPFS is a pull.** Jobs cannot reach `ipcr.localhost` (the host's loopback), so they
push to the staging registry and `ipcrd` imports from it. `ipcrd` needed no change for this.
The two legs do not depend on each other: a repository can be built without being mirrored
(private repositories), and mirrored without being built (no workflow).
## Trust model
## The bridge
- `cib` runs CI only for changes from a **delegate** of the repository (`!AnyDelegate`): release
tags, the default branch and patches. A build can publish an image under the forge's IPNS name,
so a build gets the trust of a release. Patches from non-delegates are never run.
- The node's key (which signs job COBs) never enters a job: jobs get a self-contained checkout.
- The adapter refuses private repositories, because run logs are public.
- To run outsiders' patches safely, they would need a separate daemon with no route to the staging
registry. Not built.
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`.
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
`gitea_admin` with the default app password and does four things:
- mints the daemon's token, `public-only` + `read:repository`;
- generates the webhook secret;
- creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`),
matched by URL so it is never duplicated;
- creates the example `gitea_admin/ipcr-hello`.
- **`serve`**:
- `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the
repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds.
- One worker, with a coalescing queue: a burst of deliveries is one sync.
- A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames
and visibility changes, plus any lost delivery.
- `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future
admin pages, such as Radicle settings.
### Mirror semantics
| Gitea | Radicle |
| --- | --- |
| public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical |
| push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync <rid> --announce` |
| tag created (git or web UI) | same; becomes canonical `refs/tags/<tag>` |
| tag deleted | removed from the node's namespace; **the canonical `refs/tags/<tag>` stays** (see findings) |
| default branch changed | `rad id update … defaultBranch` |
| rename / transfer | same RID (state is keyed by Gitea's repository ID) |
| made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be |
| private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) |
| empty | waits for its first push |
The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The
code checks visibility again anyway.
## Trust
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
[naming.md](naming.md) is weaker for it.
- **Anyone who can push to a repository can publish images.** A job can push any name to the
unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS
key, including images built from private repositories. Registration is off, so every user was
created by the admin.
- **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27.
- The page, the tips and `rationale.md` say all of this plainly.
- **Follow-up:** per-repository push credentials, with the bridge acting as a registry token
server, would tie an image name to its repository.
## Workflow contract
A repository opts in by committing a workflow under `.github/workflows/` (`.gitea/` and
`.forgejo/` are also read). The template on the landing page:
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
```yaml
on:
push:
tags: ['v*']
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ vars.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
```
- `vars.IMAGE` = `localhost:5000/<repository name>`; `vars.REGISTRY` = `localhost:5000`. No secret.
- Two lines differ from a GitHub-hosted workflow: `context: .` and `driver-opts: network=host`
(both explained below).
- `github.repository` is `rad/<name>`; `RADICLE_RID` is set as an env var and a var.
- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret.
`docker/metadata-action` lowercases the name.
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
## Findings
Each of these silently produced the wrong result rather than an error.
Carried over from v1 and v2; each one silently produced the wrong result rather than an error.
### act
1. **Jobs must share the daemon's network namespace.** `localhost:5000` is the registry only on
forge-docker's own network, and Docker/BuildKit accept plain HTTP for `localhost` only. The runner
config sets `container.network: host`. As a consequence, compose service names do not resolve
inside jobs, so `GITEA_INSTANCE_URL` is Gitea's public address.
2. **`docker/build-push-action` defaults to a Git context** (`<server>/<repo>.git#<ref>`), which needs
credentials here. Hence `context: .`.
3. **buildx's builder container** does not share the job's network. `driver-opts: network=host` puts
it on the daemon's, where `localhost:5000` is the registry.
4. **act_runner's `run.sh` reads the registration token once** and gives up after 10 tries. The token
is minted by an install step that runs after the stack is up, so the compose entrypoint waits for
it first.
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without
one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it.
The bridge adds the rule right after `rad init`.
1. **act does not apply `on:` branch/tag filters.** A workflow with `on: push: tags: ['v*']` ran for
a push to `main`. GitHub and Gitea filter server-side; the adapter does it here
([filter.go](../ci/adapter/filter.go), GitHub's pattern syntax, tested).
2. **Without a git remote, `github.repository` is `nektos/act`;** with a non-GitHub remote it is the
whole URL. The adapter passes `--env GITHUB_REPOSITORY=rad/<name>`.
3. **`actions/checkout` is skipped** by act, which copies the working directory into the job. So the
checkout must be a self-contained repository. A `git clone --shared` of storage left a `.git`
whose alternates pointed outside the job (`unable to normalize alternate object path`). The
adapter now fetches the one commit, depth 1, with `uploadpack.allowAnySHA1InWant` on the serving
side — what `actions/checkout` does by default.
4. **Jobs run on the daemon's host network** (`--network host`, act's default). That is what makes
`localhost:5000` reach the staging registry. It is also why compose service names do not
resolve inside jobs (`getaddrinfo ENOTFOUND rad-actions`): the adapter advertises its own IP
address instead.
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
### GitHub-isms in common actions
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote.
7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but
`refs/tags/<tag>` keeps the last target. Tags are meant to be immutable, so this was left alone.
8. **Gitea's default webhook allow-list (`external`) blocks the bridge.** It is on the server's
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container.
`private` would let any user's webhook reach every container on the shared network.
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
`--rid` flag.
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
network.
5. **`docker/metadata-action` calls the GitHub REST API** (`GET /repos/{owner}/{repo}`) and fails
with `Bad credentials` without it. The adapter serves that one call for the length of a run, from
what the node knows, and sets `GITHUB_API_URL` to it ([api.go](../ci/adapter/api.go)). `html_url`
is the Radicle explorer page, so the image's `org.opencontainers.image.source` label points there.
6. **`docker/build-push-action` defaults to a Git context** (`https://github.com/<repo>.git#<ref>`),
which does not exist here. Hence `context: .`.
7. **buildx's builder container** does not share the job's network; `driver-opts: network=host`
puts it on the daemon's, where `localhost:5000` is the registry. Docker and BuildKit accept
plain HTTP for `localhost` only, which is why the registry shares the daemon's namespace.
## Verified
### radicle-ci-broker (cib) 0.32.1
Local integration (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker
network):
8. **Tags arrive as pushes.** A tag event reaches the adapter as `event_type: "push"` with the tag's
name in `branch` (src/msg.rs), so the first tag build ran as if `v1.1.1` were a branch. The
adapter looks the name up in storage (`refs/namespaces/*/refs/tags/<name>`).
9. **`!HasFile` matches files only,** never directories (it checks `into_blob()`), despite its doc
comment. A filter on `.github/workflows` therefore never matched and nothing ran. It was dropped;
the adapter answers "no workflow" in about a second.
10. **No job COB for tag builds.** The job is keyed by the event's tip, which for an annotated tag is
the tag object; creating it fails silently and the run ends with `NoJob`. Branch builds get their
COB. Upstream fix: peel the tag before creating the job.
11. **Default log level is trace:** every gossip message the node sees. The image runs
`--log-level info`.
12. **cib must not start before the node's control socket exists.** It restart-loops a few times on
first install, then settles (`restart: unless-stopped`).
- `setup` run twice leaves exactly one system hook.
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
- A tag created through Gitea's API was canonical on Radicle within seconds, through the webhook.
- A private repository never appeared.
- A public repository made private was frozen.
### Radicle
13. **The node only reacts to what it receives** from other nodes: pushes come from the developer's
own node. The forge node still needs `rad seed <rid> --scope all` per repository (seeding policy
`block`).
14. **Commit and tag signing need an ssh-agent** holding the Radicle key.
## Verified (wisera, 2026-10-04)
`ipcr-hello` (`rad:zc9XLrmUrt2xSgTLcEYfKN8eZuxS`), tag `v1.1.2` pushed from a delegate's node:
| Step | Time |
| --- | --- |
| tag pushed | 12:54:21 |
| build finished (first run on the box: act's image pulled cold) | 13:01:06 |
| imported into IPFS and published under IPNS | ~13:02:30 |
| pulled on wisera, and on holyhorse (26 s) | after |
Later, with `metadec.eth` pointing at the forge's publisher name (see [naming.md](naming.md)),
`ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.1.2` resolved and pulled on wisera.
The full chain on a PCS (runner → staging → IPCR → pull from a second server) is in
[Open items](#open-items) until it is run on a test box.
## Open items
- **Publish the images.** `ghcr.io/yundera/rad-actions` (workflow `rad-actions.yml`, tag
`rad-actions-v*`) and `ghcr.io/yundera/ipcr:1.2.0` are so far built locally only.
- **Job COBs for tags** (finding 10): an upstream patch to cib.
- **Patches from non-delegates:** a second, registry-less daemon.
- **Warm cache.** The first build pulls `catthehacker/ubuntu:act-latest` (~1.5 GB) and clones the
actions it uses; later builds reuse both (`ci/docker`, `ci/cache`).
- **Path filters** (`on.push.paths`) are not applied: a workflow with only `paths:` runs on every
matching event.
- **Store install.** Tested as a hand install that mimics Maison (compose, `.env`, `.seed/`), not yet
through the store.
- **Run the full chain on a test PCS.** Check that the runner registers, that jobs see the daemon
(`docker_host: ""` mounts its socket into each job), that a `v*` tag on `ipcr-hello` builds and
imports, and that the image pulls from a second server.
- **Publish the images**: `ghcr.io/yundera/ipcr:1.2.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.1.0`
(workflows `image.yml` and `bridge.yml`, tag `bridge-v0.1.0`).
- **Per-repository image names:** the bridge as a registry token server (see Trust).
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
- **Store install:** test through the store, not only as a hand install.
+37
View File
@@ -0,0 +1,37 @@
# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and
# the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
# Build context: this folder (bridge/).
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge .
# Radicle's official static (musl) release, checked against its published digest.
FROM alpine:3.22 AS radicle
ARG RADICLE_VERSION=1.10.1
ARG RADICLE_SHA256=9354f7f6241a88ddca1d41a080aa6337ddfc5dc682b8de78ab6d418b5782225a
# Retried: release downloads intermittently fail.
RUN apk add --no-cache xz \
&& url="https://files.radicle.dev/releases/${RADICLE_VERSION}/radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl.tar.xz" \
&& for i in 1 2 3 4 5; do wget -qO /tmp/radicle.tar.xz "$url" && break; sleep $((i * 5)); done \
&& echo "${RADICLE_SHA256} /tmp/radicle.tar.xz" | sha256sum -c - \
&& mkdir /out && tar -xJf /tmp/radicle.tar.xz -C /out --strip-components=2 \
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/rad" \
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/git-remote-rad"
FROM alpine:3.22
ARG VERSION=dev
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \
org.opencontainers.image.source="https://github.com/Yundera/ipcr" \
org.opencontainers.image.version="$VERSION"
RUN apk add --no-cache ca-certificates git \
# Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
&& git config --system safe.directory '*'
COPY --from=radicle /out/ /usr/local/bin/
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge
ENV RAD_HOME=/radicle-home \
HOME=/tmp
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image.
CMD ["ipcr-forge-bridge", "serve"]
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func sign(secret, body string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(body))
return hex.EncodeToString(m.Sum(nil))
}
func TestValidSignature(t *testing.T) {
body := `{"repository":{"id":7}}`
if !validSignature([]byte("s3cret"), []byte(body), sign("s3cret", body)) {
t.Error("good signature rejected")
}
for _, sig := range []string{"", "zz", sign("other", body), sign("s3cret", body+" ")} {
if validSignature([]byte("s3cret"), []byte(body), sig) {
t.Errorf("bad signature %q accepted", sig)
}
}
}
func TestHook(t *testing.T) {
dir := t.TempDir()
secretFile := filepath.Join(dir, "hook-secret")
var got []int64
h := &hookHandler{secretFile: secretFile, enqueue: func(id int64) { got = append(got, id) }}
post := func(body, sig string) int {
r := httptest.NewRequest("POST", "/hooks/gitea", strings.NewReader(body))
r.Header.Set("X-Gitea-Signature", sig)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
body := `{"ref":"refs/tags/v1.0.0","repository":{"id":42,"full_name":"a/b"}}`
if c := post(body, sign("k", body)); c != http.StatusServiceUnavailable {
t.Errorf("no secret yet: %d", c)
}
os.WriteFile(secretFile, []byte("k\n"), 0o600)
if c := post(body, sign("x", body)); c != http.StatusUnauthorized {
t.Errorf("bad signature: %d", c)
}
if c := post(body, sign("k", body)); c != http.StatusAccepted {
t.Errorf("good delivery: %d", c)
}
if c := post(`{"sender":{}}`, sign("k", `{"sender":{}}`)); c != http.StatusNoContent {
t.Errorf("no repository: %d", c)
}
if len(got) != 1 || got[0] != 42 {
t.Errorf("enqueued %v, want [42]", got)
}
}
func TestQueueCoalesces(t *testing.T) {
q := newQueue()
for _, id := range []int64{3, 1, 3, 3, 2, 1} {
q.add(id)
}
var order []int64
for {
id, ok := q.next()
if !ok {
break
}
order = append(order, id)
}
if len(order) != 3 || order[0] != 3 || order[1] != 1 || order[2] != 2 {
t.Errorf("order %v, want [3 1 2]", order)
}
q.add(3) // popped, so it may be queued again
if id, ok := q.next(); !ok || id != 3 {
t.Error("re-adding a popped repository")
}
}
func TestPublic(t *testing.T) {
r := repo{}
if !r.public() {
t.Error("plain repository")
}
for _, f := range []func(*repo){
func(r *repo) { r.Private = true },
func(r *repo) { r.Internal = true },
func(r *repo) { r.Owner.Visibility = "limited" },
func(r *repo) { r.Owner.Visibility = "private" },
} {
r := repo{}
f(&r)
if r.public() {
t.Errorf("%+v counted as public", r)
}
}
}
func TestRIDFromURL(t *testing.T) {
for in, want := range map[string]string{
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o\n": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o/z6MkppQoVh2QKyQGkPzGgEaqZcXmbeBVhZseQ2cgxuMzA5gR": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
"https://example.com/x.git": "",
"": "",
} {
if got := ridFromURL(in); got != want {
t.Errorf("ridFromURL(%q) = %q, want %q", in, got, want)
}
}
if got := ridRe.FindString("Your Repository ID (RID) is rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o\nYou can"); got != "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o" {
t.Errorf("ridRe: %q", got)
}
}
func TestPushedRefs(t *testing.T) {
out := "To rad://z2pn/z6Mk\n" +
"=\trefs/heads/main:refs/heads/main\t[up to date]\n" +
"*\trefs/tags/v1.0.0:refs/tags/v1.0.0\t[new tag]\n" +
"-\t:refs/heads/old\t[deleted]\n" +
"+\trefs/heads/dev:refs/heads/dev\t1111111...2222222 (forced update)\n" +
"Done\n"
got := strings.Join(pushedRefs(out), "; ")
want := "tags/v1.0.0 [new tag]; old [deleted]; dev 1111111...2222222 (forced update)"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestStateRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "repos.json")
s, err := loadState(path)
if err != nil {
t.Fatal(err)
}
if s.known(5) || s.get(5).ID != 5 {
t.Fatal("empty state")
}
s.put(repoState{ID: 5, FullName: "b/two", RID: "rad:z2", State: stSynced, Refs: "abc", Crefs: true})
s.put(repoState{ID: 9, FullName: "a/one", State: stWaiting})
s2, err := loadState(path)
if err != nil {
t.Fatal(err)
}
if r := s2.get(5); r.RID != "rad:z2" || r.Refs != "abc" || !r.Crefs || r.State != stSynced {
t.Errorf("reloaded %+v", r)
}
l := s2.list()
if len(l) != 2 || l[0].FullName != "a/one" || l[1].Refs != "" {
t.Errorf("list %+v", l)
}
if ids := s2.ids(); len(ids) != 2 || ids[0] != 5 || ids[1] != 9 {
t.Errorf("ids %v", ids)
}
}
func TestExampleEmbedded(t *testing.T) {
for _, p := range []string{"example/Dockerfile", "example/.gitea/workflows/build.yml", "example/README.md"} {
if _, err := exampleFiles.ReadFile(p); err != nil {
t.Errorf("%s: %v", p, err)
}
}
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") {
t.Error("example workflow lost its staging registry lines")
}
}
func TestWeb(t *testing.T) {
dir := t.TempDir()
st, _ := loadState(filepath.Join(dir, "repos.json"))
h := webHandler(st, filepath.Join(dir, "published.json"))
get := func(p string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
return w.Code, w.Body.String()
}
if c, b := get("/"); c != 200 || !strings.Contains(b, "IPCR Forge") {
t.Errorf("/: %d", c)
}
if c, _ := get("/images.json"); c != 404 {
t.Errorf("/images.json before anything is published: %d", c)
}
os.WriteFile(filepath.Join(dir, "published.json"), []byte(`{"images":{}}`), 0o644)
if c, b := get("/images.json"); c != 200 || b != `{"images":{}}` {
t.Errorf("/images.json: %d %q", c, b)
}
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
t.Errorf("/repos.json: %d %q", c, b)
}
if c, _ := get("/nothing"); c != 404 {
t.Errorf("/nothing: %d", c)
}
}
+32
View File
@@ -0,0 +1,32 @@
# Builds the Dockerfile at the root for every v* tag and pushes it to this forge's staging registry.
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
# lists the exact `image:` line to use.
#
# Differences from a GitHub-hosted workflow, both needed on this forge:
# images: localhost:5000/… the staging registry (no login, no secret)
# network=host so buildx's builder can reach that registry
name: image
on:
push:
tags: ['v*']
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: localhost:5000/${{ github.repository }}
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 }}
+6
View File
@@ -0,0 +1,6 @@
# A one-page web server: busybox httpd on port 8080, as an unprivileged user.
FROM busybox:1.37
COPY index.html /www/index.html
USER 1000:1000
EXPOSE 8080
CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]
+34
View File
@@ -0,0 +1,34 @@
# ipcr-hello
The IPCR Forge example. A tag on this repository builds its image, IPCR publishes it on IPFS,
and the repository is mirrored to Radicle.
## Try it
1. **Tag a release.** In Gitea: *Tags → New tag* (or *Releases → New release*), name it `v1.0.0`
and target `main`. From a clone it works too:
```sh
git tag v1.0.0 && git push origin v1.0.0
```
2. **Watch the build** under *Actions*. The first build on a new server takes a few minutes more,
while it downloads its build images.
3. **Find the image** on the forge's page (`ipcr-forge-<your domain>`, *Published images*), about
a minute after the build finishes. Then on this server or on any other running IPCR:
```yaml
services:
hello:
image: ipcr.localhost:4767/ipns/<forge name>/gitea_admin/ipcr-hello:1.0.0
ports: ["8080:8080"]
```
## What makes it work
- [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) pushes to `localhost:5000`, the
forge's staging registry. IPCR imports every tag it finds there.
- [`Dockerfile`](Dockerfile) is an ordinary Dockerfile. Anything that builds works.
Copy the workflow into any public or private repository on this forge to publish it the same way.
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ipcr-hello</title></head>
<body style="font: 18px/1.6 system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em">
<h1>Hello from IPFS</h1>
<p>This page is served by a container whose image was built by IPCR Forge and pulled from IPFS
through IPCR.</p>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// gitea is a minimal Gitea API client. The daemon authenticates with the token in tokenFile (read
// on every call: the setup step may write it after the service starts); the setup step with the
// admin's password instead (Gitea's token API accepts nothing else).
type gitea struct {
base string
owner string
tokenFile string
password string // setup only
http *http.Client
}
func newGitea(base, owner, tokenFile string) *gitea {
return &gitea{base: strings.TrimRight(base, "/"), owner: owner, tokenFile: tokenFile,
http: &http.Client{Timeout: 30 * time.Second}}
}
func (g *gitea) token() string {
b, _ := os.ReadFile(g.tokenFile)
return strings.TrimSpace(string(b))
}
func (g *gitea) hasToken() bool { return g.token() != "" }
// gitAuth is the header git sends to Gitea's HTTP endpoint: basic auth with the token as password,
// passed with http.extraHeader so it never appears in a URL, `ps` or a log.
func (g *gitea) gitAuth() string {
return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(g.owner+":"+g.token()))
}
// call sends a request and decodes a JSON answer into out (when non-nil and the status is 2xx).
// It returns the status; a transport error is returned as an error with status 0.
func (g *gitea) call(method, path string, body, out any) (int, error) {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, g.base+"/api/v1"+path, rd)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if g.password != "" {
req.SetBasicAuth(g.owner, g.password)
} else {
req.Header.Set("Authorization", "token "+g.token())
}
resp, err := g.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(data))
if len(msg) > 300 {
msg = msg[:300]
}
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, msg)
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
type repo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
Internal bool `json:"internal"`
Fork bool `json:"fork"`
Mirror bool `json:"mirror"`
Empty bool `json:"empty"`
DefaultBranch string `json:"default_branch"`
HTMLURL string `json:"html_url"`
Owner struct {
Login string `json:"login"`
// public | limited | private. Only a public owner's repositories are public.
Visibility string `json:"visibility"`
} `json:"owner"`
}
// public reports whether the repository may leave this server. The token is `public-only`, so
// Gitea already hides everything else; this is the second lock on the same door.
func (r *repo) public() bool {
return !r.Private && !r.Internal && (r.Owner.Visibility == "" || r.Owner.Visibility == "public")
}
// publicRepos lists every repository the token can see.
func (g *gitea) publicRepos() ([]repo, error) {
var all []repo
for page := 1; ; page++ {
var res struct {
OK bool `json:"ok"`
Data []repo `json:"data"`
}
q := url.Values{"limit": {"50"}, "page": {fmt.Sprint(page)}}
if _, err := g.call("GET", "/repos/search?"+q.Encode(), nil, &res); err != nil {
return nil, err
}
all = append(all, res.Data...)
if len(res.Data) < 50 {
return all, nil
}
}
}
// repoByID returns the repository, or nil when the token cannot see it (private, deleted).
func (g *gitea) repoByID(id int64) (*repo, error) {
var r repo
code, err := g.call("GET", fmt.Sprintf("/repositories/%d", id), nil, &r)
if code == http.StatusNotFound || code == http.StatusForbidden {
return nil, nil
}
if err != nil {
return nil, err
}
return &r, nil
}
+3
View File
@@ -0,0 +1,3 @@
module ipcr-forge-bridge
go 1.24
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
)
// hookHandler receives Gitea's system webhook. It only learns *which* repository changed: the sync
// itself re-reads everything from Gitea, so a payload is never trusted for content, and a burst of
// deliveries for one repository collapses into one sync.
type hookHandler struct {
secretFile string
enqueue func(id int64)
}
func (h *hookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
secret, _ := os.ReadFile(h.secretFile)
if len(strings.TrimSpace(string(secret))) == 0 {
http.Error(w, "not configured yet", http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if !validSignature([]byte(strings.TrimSpace(string(secret))), body, r.Header.Get("X-Gitea-Signature")) {
log.Printf("hook: bad signature from %s (event %q)", r.RemoteAddr, r.Header.Get("X-Gitea-Event"))
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var p struct {
Repository *struct {
ID int64 `json:"id"`
} `json:"repository"`
}
if err := json.Unmarshal(body, &p); err != nil || p.Repository == nil || p.Repository.ID == 0 {
// Signed, but about no repository (a user or organisation event): nothing to do.
w.WriteHeader(http.StatusNoContent)
return
}
h.enqueue(p.Repository.ID)
// Answered at once: Gitea gives a delivery 5 seconds, a sync can take minutes.
w.WriteHeader(http.StatusAccepted)
}
// validSignature checks Gitea's X-Gitea-Signature: hex HMAC-SHA256 of the raw body, no prefix.
func validSignature(secret, body []byte, sig string) bool {
got, err := hex.DecodeString(strings.TrimSpace(sig))
if err != nil || len(got) == 0 {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
return hmac.Equal(got, mac.Sum(nil))
}
+149
View File
@@ -0,0 +1,149 @@
// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
// is replicated, and this service keeps the second following the first. One way only.
//
// serve (default) webhook receiver + mirror worker + the forge's page
// setup install step: Gitea token, system webhook, example repository (idempotent)
// health exit 0 when `serve` answers, for the container healthcheck
//
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
// Each one is `rad init`ed by this node on first sight (the node's key is its only delegate) and
// then receives its branches and tags with `git push rad`. A repository that turns private or is
// deleted is frozen: no further sync, and nothing is taken back from the network (it cannot be).
//
// Triggers: Gitea's system webhook (push, create, delete, repository) for latency, and a full
// reconcile every RECONCILE for whatever a lost delivery, a rename or a visibility change missed.
//
// Environment:
//
// GITEA_URL Gitea, internal address (default http://gitea:3000)
// GITEA_OWNER the admin account (default gitea_admin)
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
// STATE_DIR repos.json and the bare mirrors (default /bridge)
// RAD_HOME the node's Radicle home (default /radicle-home)
// WEB_LISTEN the page (default :8080)
// HOOK_LISTEN the webhook, internal only (default :8081)
// HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea)
// EXAMPLE_REPO setup only: example repository to create under GITEA_OWNER; empty = none
// (default ipcr-hello)
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
// (default /srv/ipcr-state/published.json)
// RECONCILE full pass interval (default 10m)
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
var err error
switch cmd {
case "serve":
err = serve()
case "setup":
err = setup()
case "health":
err = health()
default:
err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0])
}
if err != nil {
log.Fatal(err)
}
}
func health() error {
c := &http.Client{Timeout: 5 * time.Second}
r, err := c.Get("http://127.0.0.1" + env("WEB_LISTEN", ":8080") + "/healthz")
if err != nil {
return err
}
r.Body.Close()
if r.StatusCode != http.StatusOK {
return fmt.Errorf("healthz: %s", r.Status)
}
return nil
}
func serve() error {
every, err := time.ParseDuration(env("RECONCILE", "10m"))
if err != nil {
return fmt.Errorf("RECONCILE: %w", err)
}
stateDir := env("STATE_DIR", "/bridge")
st, err := loadState(stateDir + "/repos.json")
if err != nil {
return err
}
secrets := env("SECRETS_DIR", "/secrets")
m := &mirror{
gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"),
state: st,
repoDir: stateDir + "/repos",
radHome: env("RAD_HOME", "/radicle-home"),
}
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
return err
}
q := newQueue()
go q.run(m.sync)
go reconcileLoop(m, q, every)
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
hmux := http.NewServeMux()
hmux.Handle("POST /hooks/gitea", hook)
go func() {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")))
}
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
// that turned private or was deleted is noticed and frozen). The first pass runs at start-up.
func reconcileLoop(m *mirror, q *queue, every time.Duration) {
for {
if !m.gitea.hasToken() {
// The setup step runs after the stack is up, so on a first install this is normal.
log.Printf("reconcile: waiting for the Gitea token (written by the setup step)")
time.Sleep(15 * time.Second)
continue
}
repos, err := m.gitea.publicRepos()
if err != nil {
// Gitea restarting, typically: try again soon rather than in a full interval.
log.Printf("reconcile: %v", err)
time.Sleep(time.Minute)
continue
}
seen := map[int64]bool{}
for _, r := range repos {
seen[r.ID] = true
q.add(r.ID)
}
for _, id := range m.state.ids() {
if !seen[id] {
q.add(id)
}
}
time.Sleep(every)
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// What is copied, both ways of the bare mirror: branches and tags. Not refs/pull/* (Gitea's PR
// heads), not notes or anything else. Branches and tags are forced: Gitea is the truth, and a
// force push or a moved tag there is mirrored as such. --prune makes deletions follow too.
var refspecs = []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
type mirror struct {
gitea *gitea
state *state
repoDir string // one bare repository per Gitea repository: <id>.git
radHome string
}
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
func (m *mirror) sync(id int64) {
prev := m.state.get(id)
r, err := m.gitea.repoByID(id)
if err != nil {
log.Printf("sync %d: %v", id, err)
return
}
if r == nil || !r.public() {
// Private now, or deleted. Only a repository that was mirrored has anything to record.
if m.state.known(id) && prev.State != stFrozen {
prev.State = stFrozen
prev.Error = "no longer public in Gitea: not synced any more (what was published stays on the Radicle network)"
m.save(prev)
log.Printf("sync %s: frozen, no longer public", prev.FullName)
}
return
}
if r.Fork || r.Mirror {
// Did not originate here: a fork's upstream or a mirror's source is the one to publish.
return
}
cur := prev
cur.FullName, cur.HTMLURL = r.FullName, r.HTMLURL
if r.Empty {
if cur.RID == "" {
cur.State, cur.Error = stWaiting, ""
m.save(cur)
}
return
}
if err := m.publish(r, &cur); err != nil {
cur.State, cur.Error = stError, err.Error()
log.Printf("sync %s: %v", r.FullName, err)
} else {
cur.State, cur.Error, cur.LastSync = stSynced, "", time.Now().UTC()
}
m.save(cur)
}
func (m *mirror) save(r repoState) {
if err := m.state.put(r); err != nil {
log.Printf("state: %v", err)
}
}
// publish fetches the repository from Gitea into its bare mirror, creates its Radicle repository on
// first sight, and pushes what changed.
func (m *mirror) publish(r *repo, cur *repoState) error {
dir := filepath.Join(m.repoDir, fmt.Sprintf("%d.git", r.ID))
if _, err := os.Stat(dir); err != nil {
if _, err := m.git("", "init", "--quiet", "--bare", dir); err != nil {
return err
}
}
src := m.gitea.base + "/" + r.FullName + ".git"
args := append([]string{"-c", "http.extraHeader=" + m.gitea.gitAuth(), "fetch", "--quiet", "--prune", "--no-tags", src}, refspecs...)
if _, err := m.git(dir, args...); err != nil {
return fmt.Errorf("fetch from Gitea: %w", err)
}
// rad init and the project's default branch read HEAD.
if _, err := m.git(dir, "symbolic-ref", "HEAD", "refs/heads/"+r.DefaultBranch); err != nil {
return err
}
digest, err := m.refsDigest(dir)
if err != nil {
return err
}
if cur.RID == "" {
// A previous attempt may have created it and failed afterwards: rad init leaves the remote.
if rid := m.remoteRID(dir); rid != "" {
cur.RID = rid
} else {
rid, err := m.radInit(dir, r)
if err != nil {
return fmt.Errorf("rad init: %w", err)
}
cur.RID = rid
log.Printf("sync %s: created %s", r.FullName, rid)
}
cur.DefaultBranch = r.DefaultBranch
m.save(*cur) // before anything else can fail: never init the same repository twice
} else if m.remoteRID(dir) == "" {
// The mirror was deleted (it is disposable): point a fresh one at the existing repository.
if err := m.addRemote(dir, cur.RID); err != nil {
return err
}
}
if !cur.Crefs {
if err := m.radCanonicalTags(cur.RID); err != nil {
log.Printf("sync %s: canonical tags rule: %v (tags stay under the node's namespace)", r.FullName, err)
} else {
cur.Crefs = true
}
}
if digest == cur.Refs && cur.State == stSynced && cur.DefaultBranch == r.DefaultBranch {
return nil
}
out, err := m.git(dir, append([]string{"push", "--prune", "--porcelain", "-o", "no-sync", "rad"}, refspecs...)...)
if err != nil {
return fmt.Errorf("push to Radicle: %w", err)
}
if changed := pushedRefs(out); len(changed) > 0 {
log.Printf("sync %s → %s: %s", r.FullName, cur.RID, strings.Join(changed, ", "))
}
if cur.DefaultBranch != r.DefaultBranch {
if err := m.radDefaultBranch(cur.RID, r.DefaultBranch); err != nil {
return fmt.Errorf("default branch: %w", err)
}
cur.DefaultBranch = r.DefaultBranch
}
cur.Refs = digest
m.announce(cur.RID)
return nil
}
// refsDigest summarises the mirror's branches and tags; equal digests mean nothing to push.
func (m *mirror) refsDigest(dir string) (string, error) {
out, err := m.git(dir, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(out))
return hex.EncodeToString(sum[:]), nil
}
// pushedRefs reads `git push --porcelain` output and returns the refs that changed, for the log.
// Porcelain lines are "<flag>\t<from>:<to>\t<summary>"; '=' is up to date.
func pushedRefs(out string) []string {
var changed []string
for _, line := range strings.Split(out, "\n") {
f := strings.Split(line, "\t")
if len(f) < 3 || len(f[0]) != 1 || f[0] == "=" {
continue
}
to := f[1]
if i := strings.LastIndexByte(to, ':'); i >= 0 {
to = to[i+1:]
}
changed = append(changed, strings.TrimPrefix(strings.TrimPrefix(to, "refs/heads/"), "refs/")+" "+f[2])
}
return changed
}
+54
View File
@@ -0,0 +1,54 @@
package main
import "sync"
// queue holds the repositories waiting for a sync, each at most once, served by one worker: a
// burst of webhooks for one repository costs one sync, and two syncs never race on a mirror or on
// the node's storage.
type queue struct {
mu sync.Mutex
order []int64
pending map[int64]bool
wake chan struct{}
}
func newQueue() *queue {
return &queue{pending: map[int64]bool{}, wake: make(chan struct{}, 1)}
}
func (q *queue) add(id int64) {
q.mu.Lock()
if !q.pending[id] {
q.pending[id] = true
q.order = append(q.order, id)
}
q.mu.Unlock()
select {
case q.wake <- struct{}{}:
default:
}
}
// next pops the oldest repository, or reports false when the queue is empty.
func (q *queue) next() (int64, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.order) == 0 {
return 0, false
}
id := q.order[0]
q.order = q.order[1:]
delete(q.pending, id)
return id, true
}
func (q *queue) run(work func(id int64)) {
for {
id, ok := q.next()
if !ok {
<-q.wake
continue
}
work(id)
}
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// Everything Radicle is done with the stock `rad` CLI and `git-remote-rad`, against the node's own
// home: the repository is created and signed by the node's key (its only delegate), written into
// the node's storage, and announced through the node's control socket. The key has no passphrase
// (the install step creates it that way), so nothing prompts.
var ridRe = regexp.MustCompile(`rad:z[1-9A-HJ-NP-Za-km-z]+`)
func (m *mirror) env() []string {
return append(os.Environ(),
"RAD_HOME="+m.radHome,
"GIT_TERMINAL_PROMPT=0",
// rad's output is read by people in the container log, not by a terminal.
"NO_COLOR=1",
// Identity documents are git commits; git wants a name even when rad signs them.
"GIT_AUTHOR_NAME=ipcr-forge", "GIT_AUTHOR_EMAIL=ipcr-forge@localhost",
"GIT_COMMITTER_NAME=ipcr-forge", "GIT_COMMITTER_EMAIL=ipcr-forge@localhost",
)
}
// run executes a command in dir (when set) and returns its stdout; on failure the error carries
// the end of stderr, which is where git and rad explain themselves.
func (m *mirror) run(timeout time.Duration, dir, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
cmd.Env = m.env()
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if len(msg) > 500 {
msg = "…" + msg[len(msg)-500:]
}
// Never echo an argument list: a fetch carries the Gitea token in one.
return stdout.String(), fmt.Errorf("%s %s: %v: %s", name, args[0], err, msg)
}
return stdout.String(), nil
}
func (m *mirror) git(dir string, args ...string) (string, error) {
return m.run(10*time.Minute, dir, "git", args...)
}
func (m *mirror) rad(dir string, args ...string) (string, error) {
return m.run(2*time.Minute, dir, "rad", args...)
}
// radInit creates the Radicle repository for r from its bare mirror and returns its RID. It also
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
// the `rad` remote that pushes go to.
func (m *mirror) radInit(dir string, r *repo) (string, error) {
desc := r.Description
if desc == "" {
desc = "Mirrored from " + r.HTMLURL
}
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
if err != nil {
return "", err
}
if rid := m.remoteRID(dir); rid != "" {
return rid, nil
}
if rid := ridRe.FindString(out); rid != "" {
return rid, nil
}
return "", fmt.Errorf("no repository ID in rad init's output")
}
// remoteRID reads the RID from the mirror's `rad` remote (rad://z…), or "" when there is none.
func (m *mirror) remoteRID(dir string) string {
out, err := m.git(dir, "config", "--get", "remote.rad.url")
if err != nil {
return ""
}
return ridFromURL(out)
}
func ridFromURL(u string) string {
u = strings.TrimSpace(u)
if !strings.HasPrefix(u, "rad://") {
return ""
}
z, _, _ := strings.Cut(strings.TrimPrefix(u, "rad://"), "/")
return "rad:" + z
}
// addRemote recreates what rad init sets up: fetch from the repository, push into this node's
// namespace of it.
func (m *mirror) addRemote(dir, rid string) error {
nid, err := m.rad("", "self", "--nid")
if err != nil {
return err
}
z := strings.TrimPrefix(rid, "rad:")
if _, err := m.git(dir, "remote", "add", "rad", "rad://"+z); err != nil {
return err
}
_, err = m.git(dir, "config", "remote.rad.pushurl", "rad://"+z+"/"+strings.TrimSpace(nid))
return err
}
// radCanonicalTags adds the rule that makes a delegate's tags the repository's refs/tags/*. Without
// it a tag exists only under the node's namespace, where `rad clone` and the explorer do not show
// it. One delegate (this node), so a threshold of 1.
func (m *mirror) radCanonicalTags(rid string) error {
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Canonical tags",
"--description", "Tags pushed by the forge are the repository's tags.",
"--payload", "xyz.radicle.crefs", "rules", `{"refs/tags/*":{"threshold":1,"allow":"delegates"}}`)
return err
}
func (m *mirror) radDefaultBranch(rid, branch string) error {
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Default branch",
"--description", "Follows the Gitea repository.",
"--payload", "xyz.radicle.project", "defaultBranch", fmt.Sprintf("%q", branch))
return err
}
// announce tells the network about the new refs. Best effort: a node without peers is not an error,
// and the node announces again on its own schedule.
func (m *mirror) announce(rid string) {
if _, err := m.rad("", "sync", rid, "--announce", "--timeout", "30s"); err != nil {
log.Printf("announce %s: %v", rid, err)
}
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"crypto/rand"
"embed"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
//go:embed all:example
var exampleFiles embed.FS
const tokenName = "ipcr-forge-bridge"
// setup is the install step, run after the stack is up and on every start (each action is
// idempotent). It signs in once as the admin with the server's default app password, because
// Gitea's token API accepts nothing else, and leaves:
//
// SECRETS_DIR/gitea-token public-only + read:repository: the daemon lists and clones public
// repositories, and cannot see any other. Minted only when missing.
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
// created or repaired (matched by URL), never duplicated.
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
//
// The admin password is used here only and never stored. If it was changed since install, the
// step leaves the existing token and hook alone instead of failing the start.
func setup() error {
secrets := env("SECRETS_DIR", "/secrets")
g := newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), filepath.Join(secrets, "gitea-token"))
g.password = os.Getenv("GITEA_PASSWORD")
if g.password == "" {
return errors.New("GITEA_PASSWORD is required")
}
// Gitea may still be migrating, and its admin account may not exist yet. Bounded: an install
// step that blocks forever holds the install open forever.
var code int
var err error
for i := 0; i < 80; i++ {
if code, err = g.call("GET", "/user", nil, nil); err == nil {
break
}
if code == http.StatusUnauthorized && g.hasToken() {
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
return nil
}
time.Sleep(3 * time.Second)
}
if err != nil {
return fmt.Errorf("Gitea not ready after 240s: %w", err)
}
if !g.hasToken() {
g.call("DELETE", "/users/"+g.owner+"/tokens/"+tokenName, nil, nil)
var t struct {
SHA1 string `json:"sha1"`
}
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
"name": tokenName, "scopes": []string{"public-only", "read:repository"},
}, &t); err != nil {
return fmt.Errorf("token: %w", err)
}
if err := writeSecret(g.tokenFile, t.SHA1); err != nil {
return err
}
log.Printf("setup: token %s written", tokenName)
}
secretFile := filepath.Join(secrets, "hook-secret")
secret, _ := os.ReadFile(secretFile)
if strings.TrimSpace(string(secret)) == "" {
b := make([]byte, 32)
rand.Read(b)
secret = []byte(hex.EncodeToString(b))
if err := writeSecret(secretFile, string(secret)); err != nil {
return err
}
}
if err := ensureHook(g, env("HOOK_URL", "http://ipcr-forge:8081/hooks/gitea"), strings.TrimSpace(string(secret))); err != nil {
return fmt.Errorf("webhook: %w", err)
}
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, name); err != nil {
log.Printf("setup: example repository: %v", err)
}
}
return nil
}
func writeSecret(path, value string) error {
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ensureHook creates the system webhook, or rewrites the one already pointing at url (Gitea never
// returns a hook's secret, so it is set every time rather than compared).
func ensureHook(g *gitea, url, secret string) error {
config := map[string]string{"url": url, "content_type": "json", "secret": secret, "is_system_webhook": "true"}
events := []string{"push", "create", "delete", "repository"}
var hooks []struct {
ID int64 `json:"id"`
Config map[string]string `json:"config"`
}
if _, err := g.call("GET", "/admin/hooks?type=all&limit=50", nil, &hooks); err != nil {
return err
}
for _, h := range hooks {
if h.Config["url"] == url {
_, err := g.call("PATCH", fmt.Sprintf("/admin/hooks/%d", h.ID), map[string]any{
"config": config, "events": events, "active": true, "branch_filter": "*",
}, nil)
return err
}
}
_, err := g.call("POST", "/admin/hooks", map[string]any{
"type": "gitea", "config": config, "events": events, "active": true, "branch_filter": "*",
}, nil)
if err == nil {
log.Printf("setup: system webhook → %s", url)
}
return err
}
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
// repository that already has content is left as it is: it may be the user's by now.
func ensureExample(g *gitea, name string) error {
var r repo
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
switch {
case code == http.StatusNotFound:
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
"name": name, "private": false, "default_branch": "main",
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
}, &r); err != nil {
return err
}
log.Printf("setup: created %s/%s", g.owner, name)
case err != nil:
return err
case !r.Empty:
return nil
}
var files []map[string]string
err = fs.WalkDir(exampleFiles, "example", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := exampleFiles.ReadFile(p)
if err != nil {
return err
}
files = append(files, map[string]string{
"operation": "create", "path": strings.TrimPrefix(p, "example/"),
"content": base64.StdEncoding.EncodeToString(b),
})
return nil
})
if err != nil {
return err
}
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
}, nil)
return err
}
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
// Repository states, as shown on the page.
const (
stSynced = "synced" // Radicle has what Gitea has
stError = "error" // the last sync failed; retried on the next event or reconcile
stWaiting = "waiting" // empty repository: nothing to publish yet
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
)
// repoState is what the bridge remembers per Gitea repository, keyed by Gitea's numeric ID so a
// rename or a transfer keeps its Radicle ID.
type repoState struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url,omitempty"`
RID string `json:"rid,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
State string `json:"state"`
Error string `json:"error,omitempty"`
LastSync time.Time `json:"last_sync,omitzero"`
// A digest of the refs last pushed: a reconcile that finds the same refs pushes nothing.
Refs string `json:"refs,omitempty"`
// The canonical-tags rule is in the repository's identity (see rad.go).
Crefs bool `json:"crefs,omitempty"`
}
type state struct {
mu sync.Mutex
path string
repos map[int64]*repoState
}
func loadState(path string) (*state, error) {
s := &state{path: path, repos: map[int64]*repoState{}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
var m map[string]*repoState
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
for _, r := range m {
s.repos[r.ID] = r
}
return s, nil
}
// get returns a copy of the repository's state (a zero one, with the ID set, when unknown).
func (s *state) get(id int64) repoState {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.repos[id]; ok {
return *r
}
return repoState{ID: id}
}
func (s *state) known(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.repos[id]
return ok
}
// put stores r and writes the file (renamed into place, so a reader never sees half of it).
func (s *state) put(r repoState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.repos[r.ID] = &r
m := make(map[string]*repoState, len(s.repos))
for id, v := range s.repos {
m[strconv.FormatInt(id, 10)] = v
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(filepath.Dir(s.path), "."+filepath.Base(s.path)+".new")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *state) ids() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0, len(s.repos))
for id := range s.repos {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
// list is the page's view: every repository, by name, without internal bookkeeping.
func (s *state) list() []repoState {
s.mu.Lock()
out := make([]repoState, 0, len(s.repos))
for _, r := range s.repos {
v := *r
v.Refs = ""
out = append(out, v)
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
return out
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"embed"
"encoding/json"
"net/http"
"os"
)
//go:embed web
var webFiles embed.FS
// webHandler serves the forge's public page. Everything on it is public anyway: links, the
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
// repositories (public on Radicle). Nothing here changes anything.
func webHandler(st *state, publishedFile string) http.Handler {
mux := http.NewServeMux()
page, _ := webFiles.ReadFile("web/index.html")
icon, _ := webFiles.ReadFile("web/icon.png")
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(page)
})
mux.HandleFunc("GET /icon.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "max-age=3600")
w.Write(icon)
})
mux.HandleFunc("GET /images.json", func(w http.ResponseWriter, r *http.Request) {
b, err := os.ReadFile(publishedFile)
if err != nil {
http.Error(w, "nothing published yet", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Write(b)
})
mux.HandleFunc("GET /repos.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(st.list())
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n"))
})
return mux
}

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

@@ -6,21 +6,21 @@
<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> →
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<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.
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
mirror state) 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;
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
}
@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;
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
}
}
* { box-sizing: border-box; }
@@ -65,6 +65,8 @@
.muted { color: var(--muted); }
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
.scroll { overflow-x: auto; }
.state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); }
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
</style>
</head>
@@ -78,67 +80,62 @@
<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>
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
mirrored to the Radicle peer-to-peer network.</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>
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a>
<a class="card" href="#images"><b>Published images</b><span>What this forge serves from IPFS, with the line to put in a compose file.</span></a>
<a class="card" href="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</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="step"><b>1 · Push</b><small><code>git push</code> a <code>v*</code> tag to Gitea, or create it in Gitea's web UI</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="step"><b>2 · Build</b><small>Gitea Actions runs <code>.gitea/workflows/</code> and pushes the image to <code>localhost:5000</code></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="step"><b>3 · IPFS</b><small>IPCR imports each tag and publishes it under this forge's IPNS name</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 class="step"><b>4 · 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>
<p>Steps 2 and 3 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.</p>
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
and it stays available even when this server is not. Private repositories are never copied.</p>
<h2 id="example">Try it with the example</h2>
<p>The install created <a id="link-hello" href="#"><code>gitea_admin/ipcr-hello</code></a>, a tiny web
page with a Dockerfile and the workflow that publishes it.</p>
<ol>
<li>Open it in Gitea, then <b>Tags → New tag</b> (or <b>Releases → New release</b>): name
<code>v1.0.0</code>, target <code>main</code>.</li>
<li>Follow the build under the repository's <b>Actions</b> tab.</li>
<li>About a minute after it finishes, the image appears below under
<a href="#images">Published images</a>, and the tag on <a href="#repos">Radicle</a>.</li>
</ol>
<h2 id="images">Published images</h2>
<div id="images-body"><p class="muted">Loading…</p></div>
<h2 id="publish">Publish a project</h2>
<h2 id="publish">Publish your own 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:
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
and push your code to it:
<pre><code id="clone-cmd">git remote add origin https://gitea-&lt;domain&gt;/&lt;owner&gt;/&lt;repo&gt;.git
git push -u origin main</code></pre>
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
Applications</b>).</li>
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
so it can reach that registry.
<pre><code>name: image
on:
push:
tags: ['v*']
jobs:
@@ -149,7 +146,7 @@ jobs:
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ vars.IMAGE }}
images: localhost:5000/&#36;{{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
@@ -163,33 +160,45 @@ jobs:
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>
<pre><code>git add .gitea && git commit -m "Build the image"
git tag v1.0.0
git push origin main v1.0.0</code></pre>
The image is published as <code>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.</li>
</ol>
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
a private repository. Gitea holds a pull request from a fork until someone with write access approves
its workflow run: read the workflow before you approve.</p>
<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>
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;: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>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.
<code>/ipns/…</code> is a name this forge 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>
<code>ipcr.localhost:4767/ipns/example.eth/&lt;owner&gt;/&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>
<code>ipcr.localhost:4767/ipns/example.com/&lt;owner&gt;/&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="repos">On Radicle</h2>
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node
(<code id="node-id">…</code>). Clone one with <code>rad clone &lt;id&gt;</code> from any Radicle node.</p>
<div id="repos-body"><p class="muted">Loading…</p></div>
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</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>
@@ -210,14 +219,15 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<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-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr>
<tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></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>
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
</main>
<script>
@@ -232,9 +242,18 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
a.href = url(prefix);
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
}
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
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");
all("a.gitea-link", function (a) { a.href = url("gitea"); });
all("a.radicle-link", function (a) { a.href = url("radicle"); });
document.getElementById("link-hello").href = base ? url("gitea") + "/gitea_admin/ipcr-hello" : "#";
if (base) {
document.getElementById("clone-cmd").textContent =
"git remote add origin " + url("gitea") + "/<owner>/<repo>.git\ngit push -u origin main";
}
setLink("t-gitea", "gitea", true);
setLink("t-forge", "ipcr-forge", true);
setLink("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true);
@@ -244,6 +263,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
if (cls) e.className = cls;
return e;
}
function link(href, text) { var a = el("a", text); a.href = href; return a; }
function copyButton(text) {
var b = el("button", "Copy", "copy");
b.type = "button";
@@ -255,6 +275,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
});
return b;
}
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
function cmpTags(a, b) {
if (a === "latest") return -1;
if (b === "latest") return 1;
@@ -267,33 +288,37 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
}
return 0;
}
function table(headers) {
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
headers.forEach(function (h) { head.appendChild(el("th", h)); });
t.appendChild(head);
wrap.appendChild(t);
return { wrap: wrap, t: t };
}
var body = document.getElementById("images-body");
// Published images: IPCR's published.json.
var imagesBody = 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; });
all("span.publisher", function (e) { e.textContent = pub.publisher; });
}
var repos = Object.keys(pub.images || {}).sort();
if (!repos.length) throw new Error("empty");
body.textContent = "";
imagesBody.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);
imagesBody.appendChild(el("h3", repo));
var tb = table(["Tag", "Compose line", ""]);
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"));
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
var td2 = el("td"); td2.appendChild(el("code", line));
var pin = el("div", null, "muted");
pin.appendChild(document.createTextNode("this build only: "));
@@ -301,31 +326,54 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
td2.appendChild(pin);
var td3 = el("td"); td3.appendChild(copyButton(line));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
t.appendChild(tr);
tb.t.appendChild(tr);
});
wrap.appendChild(t);
body.appendChild(wrap);
imagesBody.appendChild(tb.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"));
imagesBody.innerHTML = "";
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; 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.
// Radicle mirrors: the bridge's state.
var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (repos) {
if (!repos || !repos.length) throw new Error("empty");
reposBody.textContent = "";
var tb = table(["Repository", "Radicle", "State"]);
repos.forEach(function (r) {
var tr = el("tr");
var td1 = el("td");
td1.appendChild(r.html_url ? link(r.html_url, r.full_name) : el("span", r.full_name));
var td2 = el("td");
if (r.rid) {
td2.appendChild(base ? link(url("radicle") + "/nodes/radicle-api-" + base + "/" + r.rid, r.rid) : el("code", r.rid));
var clone = el("div", null, "muted");
clone.appendChild(el("code", "rad clone " + r.rid));
td2.appendChild(clone);
} else { td2.appendChild(el("span", "—", "muted")); }
var td3 = el("td");
td3.appendChild(el("span", r.state, "state " + r.state));
if (r.last_sync) td3.appendChild(el("div", when(r.last_sync), "muted"));
if (r.error) td3.appendChild(el("div", r.error, "muted"));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tb.t.appendChild(tr);
});
reposBody.appendChild(tb.wrap);
})
.catch(function () {
reposBody.innerHTML = "";
reposBody.appendChild(el("p", "No public repository mirrored yet. Every public repository in Gitea appears here within a minute of its first push.", "muted"));
});
// The node's ID. The Radicle API is public and CORS-enabled (the explorer calls it from the browser).
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];
}
})
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
.catch(function () {});
}
})();