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:
+379
-243
@@ -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
@@ -1,112 +1,148 @@
|
||||
# IPCR Forge — Rationale
|
||||
|
||||
IPCR Forge bundles two store apps (Radicle, IPCR), the node's own CI (`rad-actions`) and a static
|
||||
landing page. The Radicle and IPCR services are copied verbatim, so each keeps the deviations its
|
||||
standalone listing already argues. This document lists them, then covers only what the bundle adds.
|
||||
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
|
||||
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and
|
||||
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
|
||||
so each keeps the deviations its standalone listing already argues. This document lists them,
|
||||
then covers only what the bundle adds.
|
||||
|
||||
Earlier versions built with Gitea Actions, fed by a bridge that copied every Radicle repository
|
||||
into Gitea. That made two places where state could start (Gitea held a second copy of each
|
||||
repository, with its own PRs, issues and registry) and needed a glue service to keep them in step.
|
||||
Gitea, its runner and the bridge are gone; the CI now runs on the Radicle node itself.
|
||||
Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only
|
||||
Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work
|
||||
with. See docs/forge.md.
|
||||
|
||||
## What deviation / exception is being requested
|
||||
|
||||
**Inherited, unchanged** (the full argument is in each listing's `rationale.md`):
|
||||
**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`.
|
||||
|
||||
| From | Deviation |
|
||||
| --- | --- |
|
||||
| Gitea | An admin account (`gitea_admin`) created with the server's default app password |
|
||||
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
|
||||
| IPCR | `ipcr-gateway` and `ipcr-registry` run as root (no capabilities); `/etc/docker/certs.d` mounted; no authentication; ports `127.0.0.1:4767` and `4768/tcp+udp` |
|
||||
|
||||
**Added by the bundle:**
|
||||
|
||||
1. **`rad-actions-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
||||
2. **`rad-actions` mounts the Radicle node's home read-write**, including the node's key.
|
||||
3. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry is.
|
||||
4. **A public landing page, including the CI's pages.** `ipcr-forge-<domain>` and its `/ci/`
|
||||
(run reports and logs) are not behind the SSO gate.
|
||||
5. **It replaces two apps** and cannot be installed beside them: same container names, domains
|
||||
and host ports.
|
||||
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
||||
2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
|
||||
key. It publishes repositories signed by that key.
|
||||
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
|
||||
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
|
||||
4. **An install step that uses the admin password** to mint the bridge's token and to register
|
||||
that webhook.
|
||||
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
|
||||
is.
|
||||
6. **A public page.** `ipcr-forge-<domain>` is not behind the SSO gate.
|
||||
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
|
||||
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
|
||||
domains and host ports.
|
||||
|
||||
## Why it is necessary
|
||||
|
||||
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. The two ways to give
|
||||
a CI job one are the host's socket (root on the PCS for every workflow) or a daemon of its own,
|
||||
which needs `privileged`. The second confines a build to its own container and image store. The
|
||||
rootless variant does not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`;
|
||||
see Apps/Gitea/rationale.md, where it was tried first).
|
||||
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one
|
||||
in two ways:
|
||||
- the host's socket, which gives every workflow root on the PCS;
|
||||
- a daemon of its own, which needs `privileged`.
|
||||
|
||||
**2. The node's home.** The CI broker (`cib`, upstream `radicle-ci-broker`) is a client of the
|
||||
node: it subscribes to the node's events over the control socket in that folder, reads repository
|
||||
storage to check out a commit, and writes each run's result back into the repository as a job COB,
|
||||
which the node's key signs. This is the mount `radicle-api` already has. It is what removes the
|
||||
mirror: the build reads the commit where it lives, so there is no copy to drift.
|
||||
The second keeps a build inside its own container and image store. The rootless variant does
|
||||
not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`; see
|
||||
Apps/Gitea/rationale.md, where it was tried first). The runner itself is a separate, unprivileged
|
||||
container that reaches the daemon over a unix socket.
|
||||
|
||||
**3. The network link.** IPCR publishes images by *pulling* them: it polls a registry and imports
|
||||
every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach `ipcr.localhost` (that
|
||||
name is the host's loopback), so a workflow pushes to a staging registry instead. That registry
|
||||
shares the daemon's network namespace, so it is `localhost:5000` to every job — the only address
|
||||
Docker and BuildKit accept plain HTTP for, so no TLS or credentials are involved. `ipcr-gateway`
|
||||
reaches it as `rad-actions-docker:5000` over `ci-internal`.
|
||||
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
|
||||
node's storage, signing its refs with the node's key, and announcing them through the node's
|
||||
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge
|
||||
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
|
||||
|
||||
**4. The public pages.** The landing page holds links, documentation and image names that are
|
||||
public on IPFS anyway. `/ci/` shows `cib`'s report pages and the run logs. Radicle repositories are
|
||||
public by design, and the adapter refuses to run a private repository, so a log never shows code
|
||||
that is not already public. A login in front would stand between a developer and their build log.
|
||||
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
|
||||
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
|
||||
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
|
||||
container on the shared network.
|
||||
|
||||
**5. Replacing two apps.** The bundle is the same services, not a different product. Renaming every
|
||||
container, domain and port to coexist would break the defaults the services rely on
|
||||
(`radicle-api:8080`) and the documented addresses users type.
|
||||
**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an
|
||||
administrator signing in. The step uses the password once per start, never stores it, and
|
||||
leaves the running service holding nothing but a `public-only`, `read:repository` token. If the
|
||||
password has been changed, the step keeps the existing token and webhook instead of failing.
|
||||
|
||||
**5. The network link.** IPCR publishes images by *pulling* them: it polls a registry and
|
||||
imports every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach
|
||||
`ipcr.localhost`, because that name is the host's loopback. So a workflow pushes to a staging
|
||||
registry instead. That registry shares the daemon's network namespace, so every job sees it as
|
||||
`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS
|
||||
or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`.
|
||||
|
||||
**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway,
|
||||
and the list of repositories already public on Radicle. A login in front of it would stand between
|
||||
a stranger and the instructions for pulling an image.
|
||||
|
||||
**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays
|
||||
available without this server. Only public repositories go. A public Gitea repository is meant to
|
||||
be read by anyone, and Radicle is one more place to read it.
|
||||
|
||||
**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming
|
||||
every container, domain and port so they could coexist would break the defaults the services rely
|
||||
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
||||
|
||||
## Security mitigations in place
|
||||
|
||||
- **Only delegates can start a build.** `cib`'s filter (ci/ci-broker.yaml) runs CI only for changes
|
||||
that come from a delegate of the repository (`AnyDelegate`): release tags, the default branch and
|
||||
their patches. A stranger's patch is never run. This matters because a build can publish: what a
|
||||
job pushes to the staging registry ends up on IPFS under the repository's IPNS name. A build
|
||||
therefore gets exactly the trust of a release.
|
||||
- **The privileged daemon has no network listener.** `dockerd` gets an explicit
|
||||
`--host=unix:///run/dind/docker.sock` and `DOCKER_TLS_CERTDIR` is empty, so the entrypoint adds no
|
||||
TCP socket. The unix socket sits in a folder shared only with `rad-actions`, group `$PGID`.
|
||||
- **`rad-actions` itself runs unprivileged:** `$PUID`, every capability dropped,
|
||||
`no-new-privileges`, 512 MB. Whoever controls it controls the daemon (that is the point of the
|
||||
socket), so it accepts no input but the node's events and a workflow from a delegate.
|
||||
- **The node's key never enters a job.** Jobs run in the nested daemon from a self-contained,
|
||||
depth-1 checkout that the adapter fetched; the Radicle home is not mounted into them.
|
||||
- **The staging registry is unreachable from outside.** No port, no Caddy route, on `ci-internal`
|
||||
only (`rad-actions`, the daemon and `ipcr-gateway`). It runs as `$PUID` with no capabilities.
|
||||
- **Bounded.** One run at a time (`concurrent_adapters: 1`), 60 minutes per run, 2 GB for the daemon.
|
||||
- **The landing page cannot change anything.** `nginx-unprivileged` runs as `$PUID` with a
|
||||
read-only root filesystem, no capabilities, `no-new-privileges` and 32 MB. It mounts the page,
|
||||
its config, IPCR's `state/` and the CI's `html/`, all read-only, and serves static files only.
|
||||
From `state/` it maps exactly one file (`/images.json` → `published.json`).
|
||||
- **The CI is severable.** Stopping `rad-actions` and `rad-actions-docker` removes the privileged
|
||||
container and leaves a working Radicle node and IPCR registry.
|
||||
- **Smaller than what it replaces.** Gone: an admin account with the default password, two minted
|
||||
tokens, the `DAC_READ_SEARCH` capability IPCR needed to read one of them, and a second git server.
|
||||
**The bridge**
|
||||
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
|
||||
`read:repository`. Gitea hides every private or limited repository from it, and it can write
|
||||
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
|
||||
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
|
||||
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
|
||||
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
|
||||
can at most cause a pointless sync. Port 8081 has no Caddy route.
|
||||
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
|
||||
`no-new-privileges`, 256 MB. One worker does one sync at a time.
|
||||
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home.
|
||||
|
||||
**The CI daemon**
|
||||
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
|
||||
empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`,
|
||||
group `$PGID`.
|
||||
- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in
|
||||
the nested daemon, never on the host's.
|
||||
- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits
|
||||
on `ci-internal` only. It runs as `$PUID` with no capabilities.
|
||||
- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon.
|
||||
- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and
|
||||
leaves a working Gitea, Radicle node and IPCR registry.
|
||||
|
||||
**Gitea**
|
||||
- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access
|
||||
waits for "Approve and run".
|
||||
- **Registration is off.** Every account is created by the admin.
|
||||
- **Who can publish is stated** in the tips and on the page. Write access to any repository means
|
||||
being able to publish images under the forge's name. Images from private repositories become
|
||||
public on IPFS.
|
||||
|
||||
**The page**
|
||||
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
|
||||
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
||||
|
||||
## Alternatives considered and rejected
|
||||
|
||||
| Alternative | Why not |
|
||||
| --- | --- |
|
||||
| Keep Gitea, fed by a bridge (previous version) | Two sources of state (a second copy of each repository, with its own PRs and registry) and a glue service to keep them in step. |
|
||||
| Gitea as a strict read-only mirror | Still a second git server and a sync to babysit, for one thing it adds: a web view of builds, which `cib`'s pages now provide. |
|
||||
| Gitea's `act_runner` in `exec` mode | Same engine as `act` (a fork), but built for Gitea; nektos `act` is what people run on their laptops. |
|
||||
| `cib`'s native adapter, or Ambient | Neither runs GitHub-style workflows; Ambient needs KVM. |
|
||||
| `ipcrd` accepting pushes itself | A push endpoint would be a second registry implementation. The staging registry plus IPCR's existing import is byte-identical to `nerdctl push` and needed no change. |
|
||||
| Host Docker socket for jobs | Any workflow would get root on the host. |
|
||||
| Run patches from anyone | Their build could publish under the repository's IPNS name. Needs a separate daemon with no registry first. |
|
||||
| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing |
|
||||
| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` |
|
||||
| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer |
|
||||
| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs |
|
||||
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
|
||||
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
|
||||
| Host Docker socket for jobs | Any workflow would get root on the host |
|
||||
| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No job COB for tag builds.** cib 0.32.1 keys a job by the event's tip, which for an annotated
|
||||
tag is the tag object rather than a commit, and creating that job fails (silently; the log only
|
||||
shows `NoJob` when the run finishes). Branch and patch builds get their job COB. Tag builds still
|
||||
appear in `/ci/` with their log. Upstream fix: peel the tag before creating the job.
|
||||
- **Tags arrive as pushes.** cib sends a tag event as `event_type: push` with the tag's name in
|
||||
`branch`. The adapter tells them apart by looking for that name under `refs/tags/` in storage.
|
||||
- **`HasFile` matches files only**, not folders, so cib cannot pre-filter on a workflow folder; the
|
||||
adapter answers "no workflow" itself.
|
||||
- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no
|
||||
further sync). What already reached the network stays there.
|
||||
- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own
|
||||
namespace only.
|
||||
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
|
||||
delegate.
|
||||
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
|
||||
Per-repository credentials, issued by the bridge, are a planned follow-up.
|
||||
|
||||
## Data protection
|
||||
|
||||
@@ -114,12 +150,15 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
|
||||
|
||||
| Folder | Holds |
|
||||
| --- | --- |
|
||||
| `radicle/` | node identity (key pair), seeded repositories (including job COBs), Caddy config |
|
||||
| `ci/` | `cib`'s database (`state/`), report pages and run logs (`html/`), act's action cache, the daemon's image store (`docker/`), the staging registry (`registry/`, disposable) |
|
||||
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
|
||||
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
|
||||
| `bridge/` | `state/`: the Gitea → Radicle mapping and one bare mirror per repository (disposable); `secrets/` (0700): its Gitea token and the webhook secret |
|
||||
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) |
|
||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
|
||||
| `site/`, `web/` | the landing page and its nginx config, re-rendered on every start |
|
||||
|
||||
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is
|
||||
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
|
||||
matter on the network (Radicle's node key, Kubo's node key and IPNS keys) survive uninstall and
|
||||
reinstall, so published repositories and image names stay valid.
|
||||
matter on the network survive uninstall and reinstall, so published repositories and image names
|
||||
stay valid:
|
||||
- Radicle's node key;
|
||||
- Kubo's node key and IPNS keys.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# act_runner configuration for IPCR Forge. Only the settings that differ from upstream's defaults
|
||||
# are written out — `act_runner generate-config` prints the full set. From Apps/Gitea's, except
|
||||
# where marked "Forge:".
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# The file the runner's own registration is kept in, relative to /data.
|
||||
file: .runner
|
||||
# One job at a time. A PCS shares its CPU with everything else installed on
|
||||
# it, and an image build is the least polite thing on the box.
|
||||
capacity: 1
|
||||
timeout: 1h
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
# Overrides the labels stored at registration (.runner). Gitea's own job images, which carry
|
||||
# node, bash, git and the docker CLI with buildx, so marketplace actions (actions/checkout,
|
||||
# docker/build-push-action, ...) run as they do on GitHub.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||
|
||||
cache:
|
||||
# The cache server binds an address inside the runner container, which the
|
||||
# job containers — created by the separate Docker daemon — cannot route to.
|
||||
# Left on, every `actions/cache` step fails noisily instead of being skipped.
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
# Forge: jobs run on the daemon's host network, which is the forge-docker container's own
|
||||
# namespace — where the staging registry listens as localhost:5000. Plain HTTP to localhost is
|
||||
# the one thing Docker and BuildKit accept without TLS, so a workflow needs no login and no
|
||||
# certificate. Jobs still reach the internet (actions, base images) and Gitea through its public
|
||||
# address.
|
||||
network: host
|
||||
privileged: false
|
||||
# Empty: the runner's own daemon (DOCKER_HOST) is mounted into each job, so `docker build`
|
||||
# and buildx work in a job, against the same daemon (and the same localhost:5000).
|
||||
docker_host: ""
|
||||
force_pull: false
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -1,334 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IPCR Forge</title>
|
||||
<link rel="icon" href="icon.png">
|
||||
<!--
|
||||
Plain file, deliberately not a Maison .tmpl: it contains no dollar-brace sequence, and every
|
||||
address is derived in the browser from this page's own hostname (ipcr-forge-<domain> →
|
||||
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
||||
Live data: /images.json (IPCR's published.json), /ci/ (the CI broker's report pages) and the
|
||||
Radicle API's node info.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
||||
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
||||
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
|
||||
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
|
||||
header img { width: 64px; height: 64px; }
|
||||
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
|
||||
h2 { font-size: 21px; margin: 40px 0 12px; }
|
||||
h3 { font-size: 16px; margin: 20px 0 6px; }
|
||||
p { margin: 8px 0; }
|
||||
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
|
||||
a { color: var(--accent); }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
||||
text-decoration: none; color: inherit; display: block; }
|
||||
a.card:hover { border-color: var(--accent); }
|
||||
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
|
||||
.card span { color: var(--muted); font-size: 14px; }
|
||||
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
|
||||
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 10px 12px; font-size: 14px; }
|
||||
.step b { display: block; font-size: 15px; }
|
||||
.step small { color: var(--muted); }
|
||||
.arrow { align-self: center; color: var(--muted); }
|
||||
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
|
||||
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
||||
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
|
||||
overflow-x: auto; line-height: 1.5; }
|
||||
pre code { background: none; padding: 0; }
|
||||
ol { padding-left: 22px; }
|
||||
li { margin: 6px 0; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 10px; overflow: hidden; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td code { word-break: break-all; }
|
||||
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
||||
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
|
||||
.muted { color: var(--muted); }
|
||||
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
||||
.scroll { overflow-x: auto; }
|
||||
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<header>
|
||||
<img src="icon.png" alt="">
|
||||
<div>
|
||||
<h1>IPCR Forge</h1>
|
||||
<div class="muted" id="host"></div>
|
||||
</div>
|
||||
</header>
|
||||
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server, without a central
|
||||
service. Code lives on Radicle, this node builds it with GitHub-style workflows, and IPCR serves
|
||||
the image from IPFS.</p>
|
||||
|
||||
<div class="cards">
|
||||
<a class="card" id="link-radicle" href="#"><b>Radicle</b><span>Browse repositories, issues and patches. Public.</span></a>
|
||||
<a class="card" href="ci/"><b>CI runs</b><span>Every build this node ran, with its log and result. Public.</span></a>
|
||||
<a class="card" href="#images"><b>Published images</b><span>What this server serves from IPFS, with the line to put in a compose file.</span></a>
|
||||
</div>
|
||||
|
||||
<h2 id="how">How it works</h2>
|
||||
<div class="pipe">
|
||||
<div class="step"><b>1 · Push</b><small><code>git push rad</code> a <code>v*</code> tag to the Radicle network</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>2 · Trigger</b><small>this node receives the tag and the Radicle CI broker picks it up</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>3 · Build</b><small><code>act</code> runs the repo's <code>.github/workflows/</code> and pushes the image</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>4 · IPFS</b><small>IPCR imports each tag and publishes it under an IPNS name</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>5 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
||||
</div>
|
||||
<p>Steps 2 to 4 are automatic and usually take a few minutes. The very first build on this server
|
||||
takes longer (about 5 to 10 minutes) while it downloads its build images. A repository needs no
|
||||
configuration beyond committing a workflow. There is no second copy of it anywhere: the build
|
||||
reads the commit straight from this node. Builds of the default branch and of patches also
|
||||
write their result back into the repository, signed by the node, as a Radicle job.</p>
|
||||
<p class="note">Only changes signed by one of the repository's delegates are built: release tags,
|
||||
the default branch and their patches. A build can publish an image, so it gets the trust of a
|
||||
release.</p>
|
||||
|
||||
<h2 id="images">Published images</h2>
|
||||
<div id="images-body"><p class="muted">Loading…</p></div>
|
||||
|
||||
<h2 id="publish">Publish a project</h2>
|
||||
<ol>
|
||||
<li><b>Install Radicle</b> on your machine and create an identity: see
|
||||
<a href="https://radicle.xyz/" target="_blank" rel="noopener">radicle.xyz</a>, then <code>rad auth</code>.
|
||||
Start your own node, which carries your pushes: <code>rad node start</code>.</li>
|
||||
<li><b>Connect to this server's node</b> so your pushes reach it:
|
||||
<pre><code id="connect-cmd">rad node connect <node-id>@<this server>: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 && git commit -m "Build the image"</code>).
|
||||
<pre><code>on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ 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 }}</code></pre></li>
|
||||
<li><b>Release:</b>
|
||||
<pre><code>git tag -a v1.0.0 -m v1.0.0
|
||||
git push rad main v1.0.0</code></pre>
|
||||
Follow the build under <a href="ci/">CI runs</a>. A minute after it finishes, the tag appears
|
||||
under <a href="#images">Published images</a>.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="use">Use an image</h2>
|
||||
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
||||
server and on any server running IPCR:</p>
|
||||
<pre><code>services:
|
||||
myapp:
|
||||
image: ipcr.localhost:4767/ipns/k51qzi5uqu5d…:1.0.0 # a tag: follows new builds of 1.0.0
|
||||
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
|
||||
<p><code>/ipns/<name>:<tag></code> is a name the publisher can move to a new build.
|
||||
<code>/ipfs/<cid></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/<repo>: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/<repo>:1.0.0</code>.</li>
|
||||
</ul>
|
||||
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
|
||||
|
||||
<h2 id="install">Pull from another server</h2>
|
||||
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
|
||||
from this server and from every other server that already pulled it.</p>
|
||||
<h3>On a Yundera server</h3>
|
||||
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
|
||||
<h3>On any Linux server with Docker 29+</h3>
|
||||
<pre><code>mkdir -p /DATA/AppData/ipcr && cd /DATA/AppData/ipcr
|
||||
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
|
||||
install -d -m 0700 -o 1000 -g 1000 kubo && install -d -o 1000 -g 1000 init.d
|
||||
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
|
||||
chmod 0755 init.d/01-ipcr-node.sh
|
||||
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' > .env
|
||||
docker compose up -d</code></pre>
|
||||
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
|
||||
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
|
||||
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
|
||||
|
||||
<h2 id="addresses">Addresses on this server</h2>
|
||||
<div class="scroll"><table>
|
||||
<tr><th>Address</th><th>What</th></tr>
|
||||
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
|
||||
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
|
||||
<tr><td><a href="ci/">ipcr-forge-…/ci/</a></td><td>CI runs and their logs (public)</td></tr>
|
||||
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
|
||||
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
|
||||
</table></div>
|
||||
|
||||
<footer>IPCR Forge · Radicle · Radicle CI broker · act · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
var host = location.hostname;
|
||||
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
|
||||
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
|
||||
function setLink(id, prefix, text) {
|
||||
var a = document.getElementById(id);
|
||||
if (!a) return;
|
||||
a.href = url(prefix);
|
||||
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
||||
}
|
||||
document.getElementById("host").textContent = base || host;
|
||||
setLink("link-radicle", "radicle");
|
||||
Array.prototype.forEach.call(document.querySelectorAll("a.radicle-link"), function (a) { a.href = url("radicle"); });
|
||||
setLink("t-radicle", "radicle", true);
|
||||
setLink("t-radicle-api", "radicle-api", true);
|
||||
|
||||
function el(tag, text, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (text != null) e.textContent = text;
|
||||
if (cls) e.className = cls;
|
||||
return e;
|
||||
}
|
||||
function copyButton(text) {
|
||||
var b = el("button", "Copy", "copy");
|
||||
b.type = "button";
|
||||
b.addEventListener("click", function () {
|
||||
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
||||
} else { window.prompt("Copy:", text); }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function cmpTags(a, b) {
|
||||
if (a === "latest") return -1;
|
||||
if (b === "latest") return 1;
|
||||
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
|
||||
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
var x = pa[i] || "", y = pb[i] || "";
|
||||
var nx = parseInt(x, 10), ny = parseInt(y, 10);
|
||||
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
|
||||
if (x !== y) return x < y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var body = document.getElementById("images-body");
|
||||
fetch("images.json", { cache: "no-store" })
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||
.then(function (pub) {
|
||||
var reg = pub.registry || "ipcr.localhost:4767";
|
||||
if (pub.publisher) {
|
||||
document.getElementById("publisher-name").textContent = pub.publisher;
|
||||
Array.prototype.forEach.call(document.querySelectorAll("span.publisher"), function (e) { e.textContent = pub.publisher; });
|
||||
}
|
||||
var repos = Object.keys(pub.images || {}).sort();
|
||||
if (!repos.length) throw new Error("empty");
|
||||
body.textContent = "";
|
||||
repos.forEach(function (repo) {
|
||||
var r = pub.images[repo];
|
||||
body.appendChild(el("h3", repo));
|
||||
var wrap = el("div", null, "scroll");
|
||||
var t = el("table");
|
||||
var head = el("tr");
|
||||
["Tag", "Compose line", ""].forEach(function (h) { head.appendChild(el("th", h)); });
|
||||
t.appendChild(head);
|
||||
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
||||
var info = r.tags[tag];
|
||||
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
|
||||
var tr = el("tr");
|
||||
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||
if (info.at) td1.appendChild(el("div", info.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
||||
var td2 = el("td"); td2.appendChild(el("code", line));
|
||||
var pin = el("div", null, "muted");
|
||||
pin.appendChild(document.createTextNode("this build only: "));
|
||||
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
|
||||
td2.appendChild(pin);
|
||||
var td3 = el("td"); td3.appendChild(copyButton(line));
|
||||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||
t.appendChild(tr);
|
||||
});
|
||||
wrap.appendChild(t);
|
||||
body.appendChild(wrap);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
body.innerHTML = "";
|
||||
body.appendChild(el("p", "Nothing published yet. Push a v* tag from a repository with a workflow (see below); its image appears here a few minutes later.", "muted"));
|
||||
});
|
||||
|
||||
// The node's ID and dialable address, for `rad node connect`. The Radicle API is public and
|
||||
// CORS-enabled (the explorer calls it from the browser). The address must be the node's own
|
||||
// externalAddresses entry (radicle-<ip>.sslip.io:8776): the *-<domain> names are behind an
|
||||
// HTTP proxy that does not carry port 8776.
|
||||
if (base) {
|
||||
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (node) {
|
||||
var addrs = (node.config && node.config.externalAddresses) || [];
|
||||
if (node.id && addrs.length) {
|
||||
document.getElementById("connect-cmd").textContent =
|
||||
"rad node connect " + node.id + "@" + addrs[0];
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user