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