name: ipcr-forge # IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle: # # 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//: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///:1.2.3 (anyone) # # 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 Gitea. ipcr-forge- 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. # # 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 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 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: # ═══ 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: 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 command: ["ipcr-forge-bridge", "serve"] cpu_shares: 30 deploy: resources: limits: 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: # HOME for git and rad. - /tmp volumes: # 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 depends_on: - gitea - radicle-node healthcheck: 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 caddy_0.reverse_proxy: "{{upstreams 8080}}" caddy_1: ipcr-forge-${APP_PUBLIC_IP_DASH}.nip.io caddy_1.import: gateway_tls caddy_1.reverse_proxy: "{{upstreams 8080}}" caddy_2: ipcr-forge-${APP_PUBLIC_IP_DASH}.sslip.io caddy_2.reverse_proxy: "{{upstreams 8080}}" networks: - pcs # ═══ 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: # act_runner holds a job's checkout and actions in memory while it copies them into the # job container: 256M was OOM-killed mid-job on the first build (holyhorse, 2026-10-04). memory: 1G 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 # browser straight to the API below, which is why that one needs its own domain. radicle: image: dirk1980/radicle-explorer-container@sha256:0e31485c38a765e83a10b1a4bf71b12d4f9fec630881d171bba736a4a56a16ce container_name: radicle user: $PUID:$PGID restart: unless-stopped cpu_shares: 80 deploy: resources: limits: memory: 128M environment: TZ: $TZ # Caddy writes its autosaved config and its (unused here) certificate store # under these two paths. The image points them at /config and /data, which are # root-owned, so as $PUID the process cannot create them and refuses to start. XDG_CONFIG_HOME: /tmp/caddy-config XDG_DATA_HOME: /tmp/caddy-data volumes: # Replaces the image's own :80 Caddyfile — see the comments in that file. - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/caddy/Caddyfile:/etc/caddy/Caddyfile:ro healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"] interval: 30s timeout: 10s retries: 3 start_period: 15s expose: - 8080 labels: caddy_0: radicle-${APP_DOMAIN} caddy_0.import: gateway_tls caddy_0.reverse_proxy: "{{upstreams 8080}}" caddy_1: radicle-${APP_PUBLIC_IP_DASH}.nip.io caddy_1.import: gateway_tls caddy_1.reverse_proxy: "{{upstreams 8080}}" caddy_2: radicle-${APP_PUBLIC_IP_DASH}.sslip.io caddy_2.reverse_proxy: "{{upstreams 8080}}" networks: - pcs # radicle-httpd — the node's read-only JSON API, on its own domain. # # This domain is deliberately NOT behind the SSO gate: the explorer above is a # static bundle running in the visitor's browser, so it is the browser that calls # this API, cross-origin. The daemon serves public repositories only (private ones # are filtered out upstream) and has no write endpoints at all. See rationale.md. radicle-api: image: dirk1980/radicle-httpd@sha256:5e95be30e4bde55b509a65fd56f8b825cafd0f5cd752c1a13e79aad75b958895 container_name: radicle-api user: $PUID:$PGID restart: unless-stopped cpu_shares: 70 deploy: resources: limits: memory: 512M command: ["--listen", "0.0.0.0:8080"] environment: TZ: $TZ volumes: # Same Radicle home as the node: the daemon reads the repository storage # directly and talks to the node over the control socket inside it. - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ healthcheck: # curl, not wget: this image carries curl and no busybox. test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/api/v1"] interval: 30s timeout: 10s retries: 3 start_period: 15s expose: - 8080 labels: caddy_0: radicle-api-${APP_DOMAIN} caddy_0.import: gateway_tls caddy_0.reverse_proxy: "{{upstreams 8080}}" caddy_1: radicle-api-${APP_PUBLIC_IP_DASH}.nip.io caddy_1.import: gateway_tls caddy_1.reverse_proxy: "{{upstreams 8080}}" caddy_2: radicle-api-${APP_PUBLIC_IP_DASH}.sslip.io caddy_2.reverse_proxy: "{{upstreams 8080}}" networks: - pcs # The node itself: speaks the Radicle peer-to-peer protocol (gossip, fetch, # announce) on TCP 8776. No HTTP, so no Caddy route and no place on the shared # network — it only needs egress, which the app-internal bridge provides. radicle-node: image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 container_name: radicle-node user: $PUID:$PGID restart: unless-stopped cpu_shares: 50 deploy: resources: limits: memory: 1G environment: TZ: $TZ volumes: - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ ports: # Radicle peer-to-peer protocol. Other nodes dial in here to fetch the # repositories this node seeds; without it the node still syncs outbound, # but nobody can fetch from it. Hence the needs-public-ip tag. - "8776:8776" healthcheck: # `rad node status` round-trips the node's control socket, which is the only # liveness signal this container has — but it also renders the peer table, and # that takes 3-8s on an idle node and longer while the node is syncing. Hence # the unusually generous timeout: a 10s one fails during the first sync and # flaps the container between healthy and unhealthy. test: ["CMD", "rad", "node", "status"] interval: 60s timeout: 30s retries: 3 start_period: 90s networks: - radicle-internal # ═══ IPCR — the IPFS container registry ════════════════════════════════════════ # Verbatim from Apps/IPCR; only its data folder moves to $AppID/ipcr/. # ── The registry Docker talks to ──────────────────────────────────────────── # ipcrd: TLS front door on 127.0.0.1:4767. Passes /ipfs/ through to the stock nerdctl # registry below, resolves /ipns/: itself, and pins every image it serves. ipcr-gateway: image: ghcr.io/yundera/ipcr:1.2.0 container_name: ipcr-gateway # Root for one reason: it writes its CA into the host's Docker trust store (the narrow # bind below), which is root-owned. See rationale.md. user: "0:0" # Root needs no capabilities to write root-owned files it owns; drop them all. cap_drop: [ALL] security_opt: ["no-new-privileges:true"] restart: unless-stopped command: ["gateway"] environment: TZ: $TZ TLS_DIR: /data/tls # Docker trusts certs.d//ca.crt and re-reads it on every pull. CA_EXPORT: /certs.d/ipcr.localhost:4767/ca.crt # Pin every image this host pulls, so the node keeps — and re-shares — what it runs. AUTO_PIN: "true" # Optional: follow a registry. Every tag of every repository it lists (or of # IPCR_IMPORT_REPOS) is copied into IPFS and published as /ipns/:; # 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://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 # /ipns//:. One name for the whole forge, which a DNSLink domain or an # ENS name can point at: ipcr.localhost:4767/ipns/example.eth/:. IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge} IMPORT_INTERVAL: 60s STATE_DIR: /data/state volumes: - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/tls:/data/tls # What the watcher already imported (tag → manifest digest). - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/data/state # The host's Docker trust store, so the CA above can be dropped into it: no daemon # reload, no daemon.json edit. The parent is mounted because Compose cannot create a # bind source whose name contains a colon (ipcr.localhost:4767). ipcrd writes exactly # one file here. See rationale.md. - /etc/docker/certs.d:/certs.d ports: # Loopback only: the client is the host's Docker daemon, which resolves ipcr.localhost # to 127.0.0.1 (RFC 6761). Not reachable from the network, and not a web UI. - "127.0.0.1:4767:4767" depends_on: - ipcr-registry healthcheck: test: ["CMD", "ipcrd", "health"] interval: 30s timeout: 5s retries: 3 start_period: 10s networks: - ipcr-internal # Forge only: reaches the staging registry for the import poller above. - ci-internal cpu_shares: 50 deploy: resources: limits: memory: 128M # ── Stock nerdctl IPFS registry ───────────────────────────────────────────── # `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads. ipcr-registry: image: ghcr.io/yundera/ipcr:1.2.0 container_name: ipcr-registry # Root because nerdctl run as any other uid assumes rootless mode and refuses to start # without a rootless containerd — which `ipfs registry serve` never uses. The service has # no volumes and no host access; capabilities are all dropped. See rationale.md. user: "0:0" cap_drop: [ALL] security_opt: ["no-new-privileges:true"] restart: unless-stopped command: ["registry"] environment: TZ: $TZ expose: - 5050 depends_on: - ipcr-kubo healthcheck: test: ["CMD", "ipcrd", "health", "http://127.0.0.1:5050/v2/"] interval: 30s timeout: 5s retries: 3 start_period: 10s networks: - ipcr-internal cpu_shares: 50 deploy: resources: limits: memory: 256M # ── Kubo, the IPFS node ───────────────────────────────────────────────────── ipcr-kubo: image: ipfs/kubo:v0.43.1 container_name: ipcr-kubo user: $PUID:$PGID restart: unless-stopped # --enable-gc: blocks nobody pinned are collected; pinned images are never touched. command: ["daemon", "--migrate=true", "--agent-version-suffix=docker", "--enable-gc"] environment: TZ: $TZ # Applied at `ipfs init` only: DHT client mode, small connection manager. Tuned for an # always-on small box (see the Kubo store app for measurements). IPFS_PROFILE: lowpower GOMEMLIMIT: 768MiB # Read by seed/init.d/01-ipcr-node.sh on every start. IPCR_SWARM_PORT: "4768" IPFS_STORAGE_MAX: 20GB volumes: - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/kubo/:/data/ipfs # Kubo's entrypoint runs every *.sh here before starting the daemon. - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/init.d/:/container-init.d:ro expose: # RPC API: no auth, so it never leaves the app's own network. - 5001 ports: # libp2p swarm — the peer protocol, not HTTP, so Caddy cannot carry it. Without it the # node still pulls, but nobody can fetch the images this host pins. 4001 is left to the # Kubo store app so both can be installed side by side. - "4768:4768/tcp" - "4768:4768/udp" healthcheck: test: ["CMD-SHELL", "ipfs --api=/ip4/127.0.0.1/tcp/5001 id > /dev/null 2>&1 || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 30s networks: - ipcr-internal cpu_shares: 50 deploy: resources: limits: memory: 1024M networks: pcs: name: ${APP_NET:-pcs} external: true # From Apps/Radicle: egress only, for the node. radicle-internal: driver: bridge # 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. ipcr-internal: driver: bridge x-casaos: architectures: - amd64 main: gitea author: Yundera developer: Yundera category: Developer tags: ["developer", "git", "ci", "needs-public-ip"] icon: icon.png screenshot_link: - screenshot-1.png - screenshot-2.png - screenshot-3.png title: en_us: IPCR Forge tagline: 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 a git forge whose releases do not depend on it staying online: • **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. 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. 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. This app bundles the Gitea, Radicle and IPCR apps. Install it instead of them, not beside them. fr_fr: | IPCR Forge est une forge git dont les versions publiées ne dépendent pas de sa disponibilité : • **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é. 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. 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. Cette application regroupe les applications Gitea, Radicle et IPCR. Installez-la à leur place, pas à côté. tips: before_install: en_us: | **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://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. **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. **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: | **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://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. **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. **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: 3000 store_app_id: ipcr-forge x-compose-app: schema_version: 2 folders: # ── Bridge ── - path: /DATA/AppData/$AppID/bridge/state user: $PUID group: $PGID - 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 ── - path: /DATA/AppData/$AppID/radicle/home user: $PUID group: $PGID - path: /DATA/AppData/$AppID/radicle/caddy user: $PUID group: $PGID # ── CI daemon ── # The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660. - path: /DATA/AppData/$AppID/ci/socket user: $PUID group: $PGID mode: "0750" - path: /DATA/AppData/$AppID/ci/docker user: $PUID group: $PGID mode: "0710" - path: /DATA/AppData/$AppID/ci/registry user: $PUID group: $PGID mode: "0700" # ── IPCR ── - path: /DATA/AppData/$AppID/ipcr/kubo user: $PUID group: $PGID mode: "0700" - path: /DATA/AppData/$AppID/ipcr/init.d user: $PUID group: $PGID files: # 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/radicle/home/config.json from: radicle/home/config.json.tmpl ensure: always mode: "0644" - path: /DATA/AppData/$AppID/radicle/caddy/Caddyfile from: radicle/caddy/Caddyfile.tmpl ensure: always mode: "0644" - path: /DATA/AppData/$AppID/ipcr/init.d/01-ipcr-node.sh from: ipcr/init.d/01-ipcr-node.sh ensure: always mode: "0755" init: # In order: Radicle's identity before its node starts, then Gitea's admin, then the runner # token and the bridge's setup, which both need that admin to exist. - name: radicle-identity image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2 user: $PUID:$PGID volumes: - /DATA/AppData/$AppID/radicle/home:/radicle-home command: ["sh", "-c", "yes '' | rad auth --alias radicle-${APP_PUBLIC_IP_DASH} --stdin"] when: absent:/DATA/AppData/$AppID/radicle/home/keys/radicle - name: gitea-admin image: gitea/gitea:1.27.3-rootless user: $PUID:$PGID phase: post_up volumes: - /DATA/AppData/$AppID/gitea/data:/var/lib/gitea - /DATA/AppData/$AppID/gitea/config:/etc/gitea command: - sh - -c - | timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done' gitea admin user create --admin --username gitea_admin \ --password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \ --must-change-password=false \ || echo "admin account already present, leaving it alone" when: always - name: gitea-runner-token image: gitea/gitea:1.27.3-rootless user: $PUID:$PGID phase: post_up network: $APP_NET volumes: - /DATA/AppData/$AppID/gitea/data:/var/lib/gitea - /DATA/AppData/$AppID/gitea/config:/etc/gitea - /DATA/AppData/$AppID/gitea/runner:/runner command: - sh - -c - | timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done' mv /runner/token.new /runner/token chmod 600 /runner/token when: absent:/DATA/AppData/$AppID/gitea/runner/.runner # 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: /