IPCR Forge v3: Gitea as the source of truth, public repos mirrored to Radicle
Users found Radicle-as-source unfriendly (v1, v2). Gitea is now where people work; its Actions build images into the staging registry IPCR follows, as in v2. - bridge/: ipcr-forge-bridge (Go). Install step mints a public-only token, registers one system webhook and creates the ipcr-hello example; the service mirrors every public repo one way to Radicle (rad init, canonical tags rule, git push rad) and serves the forge's page. - Compose: gitea + act_runner on a separate dind; tile opens Gitea; tips link the forge page. - Removed the Radicle CI broker (ci/) and the nginx landing page.
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
name: bridge
|
||||||
|
|
||||||
|
# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["bridge-v*"]
|
||||||
|
paths: ["bridge/**", ".github/workflows/bridge.yml"]
|
||||||
|
pull_request:
|
||||||
|
paths: ["bridge/**", ".github/workflows/bridge.yml"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/yundera/ipcr-forge-bridge
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
vet:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: bridge/go.mod
|
||||||
|
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
||||||
|
working-directory: bridge
|
||||||
|
|
||||||
|
image:
|
||||||
|
needs: vet
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=match,pattern=bridge-v(.*),group=1
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha
|
||||||
|
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
|
||||||
|
# amd64 only.
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: bridge
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
cache-from: type=gha,scope=bridge
|
||||||
|
cache-to: type=gha,mode=max,scope=bridge
|
||||||
+379
-243
@@ -1,77 +1,88 @@
|
|||||||
name: ipcr-forge
|
name: ipcr-forge
|
||||||
|
|
||||||
# IPCR Forge — the whole decentralised forge in one app:
|
# IPCR Forge — a forge whose images are served from IPFS and whose code is mirrored to Radicle:
|
||||||
#
|
#
|
||||||
# git push rad main v1.2.3 (developer)
|
# git push origin main v1.2.3 (developer, plain git)
|
||||||
# → radicle-node / radicle-api source of truth, peer-to-peer
|
# → gitea the source of truth: repositories, PRs, issues, users
|
||||||
# → rad-actions (cib + act) the node's own CI: GitHub-style workflows, run on the
|
# → forge-runner (act_runner) Gitea Actions: .gitea/workflows/ for the tag
|
||||||
# commit straight from the node's storage
|
# → forge-registry staging: localhost:5000/<owner>/<repo>:1.2.3 (internal only)
|
||||||
# → rad-actions-registry staging: localhost:5000/<repo>:1.2.3 (internal only)
|
# → ipcr-gateway follows that 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/<forge name>/<owner>/<repo>:1.2.3 (anyone)
|
||||||
#
|
#
|
||||||
# Radicle is the only place anything originates. There is no mirror, no second forge and no
|
# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node
|
||||||
# bridge: the CI broker reads the node's events and its storage, and writes each result back
|
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
|
||||||
# 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 Gitea. ipcr-forge-<domain> is the forge's own page, served by the bridge: how to
|
||||||
# links Radicle and the CI runs, and lists every published image with the exact `image:` line to
|
# publish, the published images with the `image:` line to use, and the Radicle mirrors.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Radicle and IPCR services are copied verbatim from their standalone listings (Apps/Radicle,
|
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
|
||||||
# Apps/IPCR), so each keeps its own comments and rationale.md. Only the data folders move, one
|
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
|
||||||
# subfolder per part: $AppID/{radicle,ci,ipcr}/.
|
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/.
|
||||||
#
|
#
|
||||||
# Replaces the Radicle and IPCR apps; it cannot sit beside them. It uses the same container
|
# Replaces the Gitea, Radicle and IPCR apps; it cannot sit beside them. It uses the same container
|
||||||
# names, domains (radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
|
# names, domains (gitea-, radicle-, radicle-api-) and host ports (8776, 4768, 127.0.0.1:4767).
|
||||||
#
|
#
|
||||||
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the CI's own Docker daemon,
|
# The handoff from CI to IPFS is a pull, run by IPCR: jobs run in the forge's own Docker daemon,
|
||||||
# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging
|
# which cannot reach ipcr.localhost (the *host's* loopback). So a workflow pushes to the staging
|
||||||
# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to
|
# registry, which shares that daemon's network namespace and is therefore `localhost:5000` to
|
||||||
# every job, and ipcr-gateway polls it over ci-internal.
|
# every job, and ipcr-gateway polls it over ci-internal.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ═══ Landing page — the tile's web UI ══════════════════════════════════════════
|
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
|
||||||
# Static: the page, its icon, IPCR's list of published images and the CI's pages (read-only).
|
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081
|
||||||
# It shows only links, documentation, image names that are public on IPFS anyway, and build
|
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
|
||||||
# logs of public repositories (the adapter does not run private ones), so it is not behind
|
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
|
||||||
# the SSO gate; Radicle is public by design.
|
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
|
||||||
ipcr-forge:
|
ipcr-forge:
|
||||||
image: nginxinc/nginx-unprivileged:1.29.3-alpine
|
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
|
||||||
container_name: ipcr-forge
|
container_name: ipcr-forge
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
read_only: true
|
read_only: true
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cpu_shares: 80
|
command: ["ipcr-forge-bridge", "serve"]
|
||||||
|
cpu_shares: 30
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 32M
|
memory: 256M
|
||||||
environment:
|
environment:
|
||||||
TZ: $TZ
|
TZ: $TZ
|
||||||
|
GITEA_URL: http://gitea:3000
|
||||||
|
GITEA_OWNER: gitea_admin
|
||||||
|
SECRETS_DIR: /secrets
|
||||||
|
STATE_DIR: /bridge
|
||||||
|
RAD_HOME: /radicle-home
|
||||||
|
IPCR_PUBLISHED: /srv/ipcr-state/published.json
|
||||||
|
RECONCILE: 10m
|
||||||
tmpfs:
|
tmpfs:
|
||||||
# nginx-unprivileged keeps its pid file and temp paths here.
|
# HOME for git and rad.
|
||||||
- /tmp
|
- /tmp
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/site:/usr/share/nginx/html:ro
|
# The node's home: its storage (where repositories are created and pushed), its control
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/web/default.conf:/etc/nginx/conf.d/default.conf:ro
|
# socket (announces) and its key, which signs everything it publishes. The same mount
|
||||||
# published.json → /images.json. Read-only; the location in default.conf serves that file only.
|
# radicle-api has.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
|
||||||
|
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge
|
||||||
|
# The Gitea token and the webhook secret the install step writes. Read-only here.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro
|
||||||
|
# published.json → /images.json. Read-only.
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
|
||||||
# The CI broker's report pages and the run logs → /ci/. Read-only.
|
depends_on:
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/html:/srv/ci:ro
|
- gitea
|
||||||
|
- radicle-node
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
|
test: ["CMD", "ipcr-forge-bridge", "health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
expose:
|
expose:
|
||||||
- 8080
|
- 8080
|
||||||
|
- 8081
|
||||||
labels:
|
labels:
|
||||||
caddy_0: ipcr-forge-${APP_DOMAIN}
|
caddy_0: ipcr-forge-${APP_DOMAIN}
|
||||||
caddy_0.import: gateway_tls
|
caddy_0.import: gateway_tls
|
||||||
@@ -84,8 +95,199 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- pcs
|
- pcs
|
||||||
|
|
||||||
# ═══ Radicle — the source of truth ═════════════════════════════════════════════
|
# ═══ Gitea — the source of truth ═══════════════════════════════════════════════
|
||||||
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/.
|
# From Apps/Gitea; its data folder moves to $AppID/gitea/.
|
||||||
|
|
||||||
|
# Gitea itself — git hosting, the Actions controller, and the OCI package
|
||||||
|
# registry, all in one process. SQLite: a personal forge never has the write
|
||||||
|
# concurrency that would justify a second container for Postgres.
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.27.3-rootless
|
||||||
|
container_name: gitea
|
||||||
|
user: $PUID:$PGID
|
||||||
|
restart: unless-stopped
|
||||||
|
cpu_shares: 70
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
environment:
|
||||||
|
TZ: $TZ
|
||||||
|
# Every URL Gitea hands out — clone URLs, the registry host, and the
|
||||||
|
# server_url the CI runner passes to jobs — is derived from ROOT_URL, so
|
||||||
|
# it has to be the address that works from outside as well as inside.
|
||||||
|
GITEA__server__ROOT_URL: https://gitea-${APP_DOMAIN}/
|
||||||
|
GITEA__server__DOMAIN: gitea-${APP_DOMAIN}
|
||||||
|
GITEA__server__HTTP_PORT: "3000"
|
||||||
|
GITEA__server__PROTOCOL: http
|
||||||
|
# The address Gitea's own CLI uses to reach the running server's internal
|
||||||
|
# API. It defaults to localhost, which is right for the server itself and
|
||||||
|
# useless from the init container that runs
|
||||||
|
# `gitea actions generate-runner-token`. It has to be set here rather than
|
||||||
|
# there: the CLI reads it out of the shared app.ini, and only this
|
||||||
|
# container's entrypoint renders environment into app.ini.
|
||||||
|
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
|
||||||
|
# Git over SSH would need a published host port of its own. Everything
|
||||||
|
# here — clone, push, CI, registry — goes over HTTPS instead.
|
||||||
|
GITEA__server__DISABLE_SSH: "true"
|
||||||
|
GITEA__server__START_SSH_SERVER: "false"
|
||||||
|
# Skips the first-run install wizard: every value it would ask for is set
|
||||||
|
# here, and the admin account is created by an init step below.
|
||||||
|
GITEA__security__INSTALL_LOCK: "true"
|
||||||
|
GITEA__database__DB_TYPE: sqlite3
|
||||||
|
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
|
||||||
|
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||||
|
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
|
||||||
|
GITEA__actions__ENABLED: "true"
|
||||||
|
# Where a bare `uses: actions/checkout@v4` is resolved from.
|
||||||
|
GITEA__actions__DEFAULT_ACTIONS_URL: github
|
||||||
|
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would
|
||||||
|
# be a second place an image could be published from.
|
||||||
|
GITEA__packages__ENABLED: "false"
|
||||||
|
# Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by
|
||||||
|
# default, any public address. Not `private`: that would let any user's webhook reach every
|
||||||
|
# container on the shared network.
|
||||||
|
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/data:/var/lib/gitea
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/config:/etc/gitea
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
expose:
|
||||||
|
- 3000
|
||||||
|
labels:
|
||||||
|
caddy_0: gitea-${APP_DOMAIN}
|
||||||
|
caddy_0.import: gateway_tls
|
||||||
|
caddy_0.reverse_proxy: "{{upstreams 3000}}"
|
||||||
|
caddy_1: gitea-${APP_PUBLIC_IP_DASH}.nip.io
|
||||||
|
caddy_1.import: gateway_tls
|
||||||
|
caddy_1.reverse_proxy: "{{upstreams 3000}}"
|
||||||
|
caddy_2: gitea-${APP_PUBLIC_IP_DASH}.sslip.io
|
||||||
|
caddy_2.reverse_proxy: "{{upstreams 3000}}"
|
||||||
|
networks:
|
||||||
|
- pcs
|
||||||
|
|
||||||
|
# ═══ CI — Gitea Actions ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# The runner: act_runner (plain, not its -dind variant) driving the separate daemon below over a
|
||||||
|
# unix socket. Runs unprivileged; whoever controls it controls that daemon, which is the point.
|
||||||
|
# Its config (seed/gitea/runner-config/config.yaml) puts jobs on the daemon's host network, where
|
||||||
|
# the staging registry is localhost:5000.
|
||||||
|
forge-runner:
|
||||||
|
image: gitea/act_runner:0.6.1
|
||||||
|
container_name: forge-runner
|
||||||
|
user: $PUID:$PGID
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
restart: unless-stopped
|
||||||
|
# The stock run.sh reads the registration token once and gives up after 10 tries; the token is
|
||||||
|
# minted by an install step that runs after the stack is up. So wait for it (or for an
|
||||||
|
# existing registration) first.
|
||||||
|
entrypoint:
|
||||||
|
- /sbin/tini
|
||||||
|
- --
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
until [ -s /data/token ] || [ -s /data/.runner ]; do echo "waiting for the runner registration token"; sleep 2; done
|
||||||
|
until [ -S /run/dind/docker.sock ]; do sleep 1; done
|
||||||
|
exec run.sh
|
||||||
|
cpu_shares: 20
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
environment:
|
||||||
|
TZ: $TZ
|
||||||
|
HOME: /data
|
||||||
|
DOCKER_HOST: unix:///run/dind/docker.sock
|
||||||
|
# Handed to every job as GITHUB_SERVER_URL, and jobs run on the daemon's network where
|
||||||
|
# `gitea` does not resolve: checkout goes out and back over HTTPS.
|
||||||
|
GITEA_INSTANCE_URL: https://gitea-${APP_DOMAIN}
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
|
||||||
|
GITEA_RUNNER_NAME: forge-runner
|
||||||
|
# Registration-time labels; runner-config/config.yaml repeats them and wins on every start.
|
||||||
|
GITEA_RUNNER_LABELS: ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04
|
||||||
|
CONFIG_FILE: /config/config.yaml
|
||||||
|
volumes:
|
||||||
|
# .runner (this runner's registration) and the registration token the init step leaves.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner:/data
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/gitea/runner-config:/config:ro
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
- forge-docker
|
||||||
|
networks:
|
||||||
|
# Egress: it polls Gitea through its public address, like the jobs.
|
||||||
|
- ci-internal
|
||||||
|
|
||||||
|
# The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the
|
||||||
|
# host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener:
|
||||||
|
# its only API is a unix socket in a folder shared with forge-runner, owned by $PGID.
|
||||||
|
forge-docker:
|
||||||
|
image: docker:29.1-dind
|
||||||
|
container_name: forge-docker
|
||||||
|
privileged: true
|
||||||
|
restart: unless-stopped
|
||||||
|
cpu_shares: 20
|
||||||
|
command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
environment:
|
||||||
|
TZ: $TZ
|
||||||
|
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
|
||||||
|
# The daemon's image store. Without it every restart re-pulls every job image.
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- ci-internal
|
||||||
|
|
||||||
|
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
|
||||||
|
# every job (act runs jobs on the host network, which is this namespace), so a plain
|
||||||
|
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
|
||||||
|
# plain HTTP to localhost only. ipcr-gateway reaches it as forge-docker:5000. Nothing
|
||||||
|
# outside ci-internal can.
|
||||||
|
forge-registry:
|
||||||
|
image: registry:3.0.0
|
||||||
|
container_name: forge-registry
|
||||||
|
user: $PUID:$PGID
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:forge-docker
|
||||||
|
cpu_shares: 20
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
environment:
|
||||||
|
TZ: $TZ
|
||||||
|
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
|
||||||
|
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
|
||||||
|
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
||||||
|
OTEL_TRACES_EXPORTER: none
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
|
||||||
|
depends_on:
|
||||||
|
- forge-docker
|
||||||
|
|
||||||
|
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════
|
||||||
|
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge
|
||||||
|
# creates and pushes repositories in this node's storage; the node replicates them.
|
||||||
|
|
||||||
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
|
# The web UI: the Radicle Explorer bundle served as static files by Caddy.
|
||||||
# It is a pure client-side app — every request it makes goes from the visitor's
|
# It is a pure client-side app — every request it makes goes from the visitor's
|
||||||
@@ -210,120 +412,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- radicle-internal
|
- radicle-internal
|
||||||
|
|
||||||
# ═══ CI — Radicle CI broker + act ═════════════════════════════════════════════════
|
|
||||||
|
|
||||||
# cib (radicle-ci-broker) follows the node's events over its control socket, keeps the ones its
|
|
||||||
# filters allow (changes signed by a delegate, in a repository with a workflow folder — see
|
|
||||||
# ci/ci-broker.yaml in the IPCR repository), and runs the rad-actions adapter for each. The
|
|
||||||
# adapter checks the commit out of the node's storage and runs the matching GitHub-style
|
|
||||||
# workflows with act, against the Docker daemon below. cib writes HTML reports, and the result
|
|
||||||
# of every run back into the repository as a job COB signed by this node.
|
|
||||||
rad-actions:
|
|
||||||
image: ghcr.io/yundera/rad-actions:0.1.0
|
|
||||||
container_name: rad-actions
|
|
||||||
user: $PUID:$PGID
|
|
||||||
# Reaps act's and git's children: cib is not an init.
|
|
||||||
init: true
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: ["no-new-privileges:true"]
|
|
||||||
restart: unless-stopped
|
|
||||||
cpu_shares: 20
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
environment:
|
|
||||||
TZ: $TZ
|
|
||||||
# The daemon jobs run in. Its socket is the only thing shared with it.
|
|
||||||
DOCKER_HOST: unix:///run/dind/docker.sock
|
|
||||||
# The images act runs a `runs-on:` label in. act's own medium images (Ubuntu with Node and
|
|
||||||
# the Docker CLI), the ones act users run on their laptops.
|
|
||||||
RAD_ACTIONS_PLATFORMS: ubuntu-latest=catthehacker/ubuntu:act-latest,ubuntu-24.04=catthehacker/ubuntu:act-24.04,ubuntu-22.04=catthehacker/ubuntu:act-22.04
|
|
||||||
# `vars` every workflow sees. IMAGE = IMAGE_PREFIX/<repository name>, the name IPCR imports.
|
|
||||||
RAD_ACTIONS_VARS: REGISTRY=localhost:5000,IMAGE_PREFIX=localhost:5000
|
|
||||||
# cib's pages link each run to its log there (served by ipcr-forge).
|
|
||||||
RAD_ACTIONS_LOG_URL: /ci/logs/
|
|
||||||
# The repository's web page, as the adapter's GitHub API stand-in reports it (html_url, which
|
|
||||||
# docker/metadata-action turns into the image's org.opencontainers.image.source label).
|
|
||||||
RAD_ACTIONS_REPO_URL: https://radicle-${APP_DOMAIN}/nodes/radicle-api-${APP_DOMAIN}/{rid}
|
|
||||||
volumes:
|
|
||||||
# The node's home: its control socket (events), its storage (checkouts, job COBs) and its
|
|
||||||
# key, which signs the job COBs. The same mount radicle-api has.
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
|
|
||||||
# cib's database, its pages and the run logs, act's action cache, and scratch checkouts.
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/state:/ci/state
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/html:/ci/html
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/cache:/ci/cache
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/home:/ci/home
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/work:/ci/work
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
|
|
||||||
depends_on:
|
|
||||||
- radicle-node
|
|
||||||
- rad-actions-docker
|
|
||||||
networks:
|
|
||||||
# Egress only: act fetches actions (actions/checkout, docker/*) from GitHub.
|
|
||||||
- ci-internal
|
|
||||||
|
|
||||||
# The daemon jobs run in: Docker-in-Docker, so jobs and the images they build never touch the
|
|
||||||
# host's daemon. See rationale.md for what `privileged` buys and what it costs. No TCP listener:
|
|
||||||
# its only API is a unix socket in a folder shared with rad-actions, owned by $PGID.
|
|
||||||
rad-actions-docker:
|
|
||||||
image: docker:29.1-dind
|
|
||||||
container_name: rad-actions-docker
|
|
||||||
privileged: true
|
|
||||||
restart: unless-stopped
|
|
||||||
cpu_shares: 20
|
|
||||||
command: ["--host=unix:///run/dind/docker.sock", "--group=$PGID"]
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 2G
|
|
||||||
environment:
|
|
||||||
TZ: $TZ
|
|
||||||
# No certificates: with an explicit --host the entrypoint adds no TCP listener to protect.
|
|
||||||
DOCKER_TLS_CERTDIR: ""
|
|
||||||
volumes:
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/socket:/run/dind
|
|
||||||
# The daemon's image store. Without it every restart re-pulls every job image.
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/docker:/var/lib/docker
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "docker", "-H", "unix:///run/dind/docker.sock", "info"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
networks:
|
|
||||||
- ci-internal
|
|
||||||
|
|
||||||
# The staging registry, in the daemon's network namespace: `localhost:5000` to the daemon and to
|
|
||||||
# every job (act runs jobs on the host network, which is this namespace), so a plain
|
|
||||||
# `docker push` or buildx with `network=host` works without TLS or credentials — Docker allows
|
|
||||||
# plain HTTP to localhost only. ipcr-gateway reaches it as rad-actions-docker:5000. Nothing
|
|
||||||
# outside ci-internal can.
|
|
||||||
rad-actions-registry:
|
|
||||||
image: registry:3.0.0
|
|
||||||
container_name: rad-actions-registry
|
|
||||||
user: $PUID:$PGID
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: ["no-new-privileges:true"]
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: service:rad-actions-docker
|
|
||||||
cpu_shares: 20
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128M
|
|
||||||
environment:
|
|
||||||
TZ: $TZ
|
|
||||||
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
|
|
||||||
# Staging only: IPCR has its own copy once imported, so old tags may be deleted.
|
|
||||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
|
||||||
OTEL_TRACES_EXPORTER: none
|
|
||||||
volumes:
|
|
||||||
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry:/var/lib/registry
|
|
||||||
depends_on:
|
|
||||||
- rad-actions-docker
|
|
||||||
|
|
||||||
# ═══ IPCR — the IPFS container registry ════════════════════════════════════════
|
# ═══ 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/.
|
||||||
|
|
||||||
@@ -353,7 +441,7 @@ services:
|
|||||||
# 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 the CI's staging registry, over ci-internal. Internal, so no credentials.
|
# Forge: watch the CI's staging registry, over ci-internal. Internal, so no credentials.
|
||||||
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://rad-actions-docker:5000}
|
IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
|
||||||
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
|
IMPORT_AUTH: ${IPCR_IMPORT_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
|
# Forge: every repository this node builds is published under one IPNS key, "forge", as
|
||||||
@@ -477,7 +565,7 @@ networks:
|
|||||||
# From Apps/Radicle: egress only, for the node.
|
# From Apps/Radicle: egress only, for the node.
|
||||||
radicle-internal:
|
radicle-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
# CI: rad-actions ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
|
# CI: the runner ↔ its daemon (and staging registry) ↔ ipcr-gateway; egress for builds.
|
||||||
ci-internal:
|
ci-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
|
# From Apps/IPCR: gateway ↔ registry ↔ kubo, nothing else.
|
||||||
@@ -487,7 +575,7 @@ networks:
|
|||||||
x-casaos:
|
x-casaos:
|
||||||
architectures:
|
architectures:
|
||||||
- amd64
|
- amd64
|
||||||
main: ipcr-forge
|
main: gitea
|
||||||
author: Yundera
|
author: Yundera
|
||||||
developer: Yundera
|
developer: Yundera
|
||||||
category: Developer
|
category: Developer
|
||||||
@@ -500,102 +588,127 @@ x-casaos:
|
|||||||
title:
|
title:
|
||||||
en_us: IPCR Forge
|
en_us: IPCR Forge
|
||||||
tagline:
|
tagline:
|
||||||
en_us: A decentralised forge in one install — Radicle, GitHub-style CI and IPFS images.
|
en_us: Gitea with Actions, images served from IPFS, code mirrored to Radicle.
|
||||||
fr_fr: Une forge décentralisée en une installation — Radicle, CI au format GitHub et images IPFS.
|
fr_fr: Gitea avec Actions, images servies depuis IPFS, code répliqué sur Radicle.
|
||||||
description:
|
description:
|
||||||
en_us: |
|
en_us: |
|
||||||
IPCR Forge is the full path from code to running container, without a central service:
|
IPCR Forge is a git forge whose releases do not depend on it staying online:
|
||||||
|
|
||||||
• **Radicle** holds your repositories on a peer-to-peer network. You push with `git push rad`.
|
• **Gitea** is where you work: repositories, pull requests, issues, and GitHub-style Actions.
|
||||||
• **Radicle CI** on this node builds them with GitHub-style workflows, triggered by a release tag.
|
Push with plain `git`.
|
||||||
• **IPCR** serves the resulting images from IPFS, so anyone can `docker pull` them by name.
|
• **IPCR** publishes every image your workflows build on IPFS, so any server running IPCR
|
||||||
|
can `docker pull` it by name, from whichever peers hold it.
|
||||||
|
• **Radicle** receives a copy of every public repository, signed by this server, on its
|
||||||
|
peer-to-peer network: anyone can clone it, even when this server is down.
|
||||||
|
|
||||||
Radicle is the single source of truth: builds read the commit straight from this node, and
|
A repository publishes images by committing a workflow under `.gitea/workflows/` that
|
||||||
their results go back into the repository as Radicle jobs. There is no mirror to keep in sync.
|
pushes to `localhost:5000`. The install creates an example, `ipcr-hello`: create a tag on it
|
||||||
|
in Gitea and watch the whole chain run.
|
||||||
|
|
||||||
A repository joins the pipeline by committing a workflow under `.github/workflows/`, as on
|
The forge's own page explains how to publish, lists every published image with the line to
|
||||||
GitHub: there is nothing to configure per project.
|
put in a compose file, and every repository mirrored to Radicle.
|
||||||
|
|
||||||
Its own page explains how to publish a project, links every build and its log, and lists
|
This app bundles the Gitea, Radicle and IPCR apps. Install it instead 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 est une forge git dont les versions publiées ne dépendent pas de sa disponibilité :
|
||||||
|
|
||||||
• **Radicle** garde vos dépôts sur un réseau pair-à-pair. Vous poussez avec `git push rad`.
|
• **Gitea** est l'endroit où l'on travaille : dépôts, pull requests, tickets, et Actions au
|
||||||
• **Radicle CI** sur ce nœud les construit avec des workflows au format GitHub, déclenchés par un tag.
|
format GitHub. On pousse avec `git`, tout simplement.
|
||||||
• **IPCR** sert les images produites depuis IPFS : n'importe qui peut les `docker pull`.
|
• **IPCR** publie sur IPFS chaque image construite par vos workflows : tout serveur équipé
|
||||||
|
d'IPCR peut la `docker pull` par son nom, depuis les pairs qui la détiennent.
|
||||||
|
• **Radicle** reçoit une copie de chaque dépôt public, signée par ce serveur, sur son réseau
|
||||||
|
pair-à-pair : n'importe qui peut la cloner, même quand ce serveur est arrêté.
|
||||||
|
|
||||||
Radicle est l'unique source de vérité : les builds lisent le commit directement sur ce nœud,
|
Un dépôt publie des images en ajoutant un workflow sous `.gitea/workflows/` qui pousse vers
|
||||||
et leurs résultats retournent dans le dépôt sous forme de jobs Radicle. Aucun miroir à
|
`localhost:5000`. L'installation crée un exemple, `ipcr-hello` : créez-y un tag dans Gitea et
|
||||||
synchroniser.
|
regardez toute la chaîne s'exécuter.
|
||||||
|
|
||||||
Un dépôt rejoint la chaîne en ajoutant un workflow sous `.github/workflows/`, comme sur
|
La page de la forge explique comment publier, liste chaque image publiée avec la ligne à
|
||||||
GitHub : rien à configurer par projet.
|
mettre dans un fichier compose, et chaque dépôt répliqué sur Radicle.
|
||||||
|
|
||||||
Sa page explique comment publier un projet, donne accès à chaque build et à son journal, et
|
Cette application regroupe les applications Gitea, Radicle et IPCR. Installez-la à leur
|
||||||
liste chaque image publiée avec la ligne à mettre dans un fichier compose.
|
place, pas à côté.
|
||||||
|
|
||||||
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 two apps.** Uninstall Radicle and IPCR first: this app uses the same addresses
|
**Start here: [the forge's page](https://ipcr-forge-$APP_DOMAIN)**: how to publish, the
|
||||||
and ports.
|
published images, the Radicle mirrors.
|
||||||
|
|
||||||
|
**Replaces three apps.** Uninstall Gitea, Radicle and IPCR first: this app uses the same
|
||||||
|
addresses and ports.
|
||||||
|
|
||||||
| Address | What |
|
| Address | What |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `https://ipcr-forge-$APP_DOMAIN` | This app: how to publish, every published image |
|
| `https://gitea-$APP_DOMAIN` | Gitea (opens from the tile). Sign in as `gitea_admin` with your default app password |
|
||||||
| `https://ipcr-forge-$APP_DOMAIN/ci/` | CI runs and their logs (public) |
|
| `https://ipcr-forge-$APP_DOMAIN` | The forge's page (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) |
|
||||||
| `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.
|
||||||
|
|
||||||
**Who can build.** Only changes signed by one of a repository's delegates trigger a build:
|
**Public repositories go to Radicle.** Every public Gitea repository is copied there, signed
|
||||||
release tags, the default branch and their patches. Builds run in their own Docker daemon.
|
by this server, within seconds of each push. That cannot be undone: making a repository
|
||||||
|
private later stops the copy, and what was published stays on the network. Private
|
||||||
|
repositories are never copied.
|
||||||
|
|
||||||
**Images reach IPFS on their own.** A workflow pushes to `vars.IMAGE` (this node's
|
**Who can publish images.** Anyone who can push to a repository here: a build can push any
|
||||||
staging registry); about a minute later the image can be pulled as
|
image name to the staging registry, and IPCR publishes what it finds there under this
|
||||||
`ipcr.localhost:4767/ipns/<name>:<tag>`, where `<name>` is the IPNS name (`k51…`) IPCR
|
forge's name. Images are public once on IPFS, even from a private repository. Read a
|
||||||
creates for the repository. The app's page lists each one with the line to copy.
|
fork's workflow before approving its run.
|
||||||
fr_fr: |
|
fr_fr: |
|
||||||
**Remplace deux applications.** Désinstallez d'abord Radicle et IPCR : cette application
|
**Commencez ici : [la page de la forge](https://ipcr-forge-$APP_DOMAIN)** : publier, les
|
||||||
utilise les mêmes adresses et ports.
|
images publiées, les copies Radicle.
|
||||||
|
|
||||||
|
**Remplace trois applications.** Désinstallez d'abord Gitea, Radicle et IPCR : cette
|
||||||
|
application utilise les mêmes adresses et ports.
|
||||||
|
|
||||||
| Adresse | Quoi |
|
| Adresse | Quoi |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `https://ipcr-forge-$APP_DOMAIN` | Cette application : publier, toutes les images publiées |
|
| `https://gitea-$APP_DOMAIN` | Gitea (s'ouvre depuis la tuile). Connexion : `gitea_admin` et votre mot de passe d'applications par défaut |
|
||||||
| `https://ipcr-forge-$APP_DOMAIN/ci/` | Builds et leurs journaux (public) |
|
| `https://ipcr-forge-$APP_DOMAIN` | La page de la forge (publique) |
|
||||||
| `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) |
|
||||||
| `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.
|
||||||
|
|
||||||
**Qui peut lancer un build.** Seuls les changements signés par un délégué du dépôt
|
**Les dépôts publics partent sur Radicle.** Chaque dépôt public de Gitea y est copié, signé
|
||||||
déclenchent un build : tags de version, branche par défaut et leurs patches. Les builds
|
par ce serveur, quelques secondes après chaque push. C'est irréversible : rendre un dépôt
|
||||||
tournent dans leur propre démon Docker.
|
privé ensuite arrête la copie, et ce qui a été publié reste sur le réseau. Les dépôts privés
|
||||||
|
ne sont jamais copiés.
|
||||||
|
|
||||||
**Les images arrivent sur IPFS d'elles-mêmes.** Un workflow pousse vers `vars.IMAGE`
|
**Qui peut publier des images.** Toute personne qui peut pousser sur un dépôt d'ici : un
|
||||||
(le registre de transit de ce nœud) ; environ une minute plus tard, l'image se tire avec
|
build peut pousser n'importe quel nom d'image vers le registre de transit, et IPCR publie ce
|
||||||
`ipcr.localhost:4767/ipns/<nom>:<tag>`, où `<nom>` est le nom IPNS (`k51…`) qu'IPCR crée
|
qu'il y trouve sous le nom de cette forge. Une image est publique une fois sur IPFS, même
|
||||||
pour le dépôt. La page de l'application les liste, prêts à copier.
|
si elle vient d'un dépôt privé. Lisez le workflow d'un fork avant d'approuver son exécution.
|
||||||
index: /
|
index: /
|
||||||
webui_port: 8080
|
webui_port: 3000
|
||||||
store_app_id: ipcr-forge
|
store_app_id: ipcr-forge
|
||||||
|
|
||||||
x-compose-app:
|
x-compose-app:
|
||||||
schema_version: 2
|
schema_version: 2
|
||||||
folders:
|
folders:
|
||||||
# ── Landing page ──
|
# ── Bridge ──
|
||||||
- path: /DATA/AppData/$AppID/site
|
- path: /DATA/AppData/$AppID/bridge/state
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
- path: /DATA/AppData/$AppID/web
|
- path: /DATA/AppData/$AppID/bridge/secrets
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
mode: "0700"
|
||||||
|
# ── Gitea ──
|
||||||
|
- path: /DATA/AppData/$AppID/gitea/data
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
- path: /DATA/AppData/$AppID/gitea/config
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
- path: /DATA/AppData/$AppID/gitea/runner
|
||||||
|
user: $PUID
|
||||||
|
group: $PGID
|
||||||
|
mode: "0700"
|
||||||
|
- path: /DATA/AppData/$AppID/gitea/runner-config
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
# ── Radicle ──
|
# ── Radicle ──
|
||||||
@@ -605,25 +718,7 @@ x-compose-app:
|
|||||||
- path: /DATA/AppData/$AppID/radicle/caddy
|
- path: /DATA/AppData/$AppID/radicle/caddy
|
||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
# ── CI ──
|
# ── CI daemon ──
|
||||||
- path: /DATA/AppData/$AppID/ci/state
|
|
||||||
user: $PUID
|
|
||||||
group: $PGID
|
|
||||||
mode: "0700"
|
|
||||||
- path: /DATA/AppData/$AppID/ci/html/logs
|
|
||||||
user: $PUID
|
|
||||||
group: $PGID
|
|
||||||
- path: /DATA/AppData/$AppID/ci/cache
|
|
||||||
user: $PUID
|
|
||||||
group: $PGID
|
|
||||||
- path: /DATA/AppData/$AppID/ci/home
|
|
||||||
user: $PUID
|
|
||||||
group: $PGID
|
|
||||||
mode: "0700"
|
|
||||||
- path: /DATA/AppData/$AppID/ci/work
|
|
||||||
user: $PUID
|
|
||||||
group: $PGID
|
|
||||||
mode: "0700"
|
|
||||||
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
|
# The daemon's socket: created by root, group $PGID (dockerd --group), mode 0660.
|
||||||
- path: /DATA/AppData/$AppID/ci/socket
|
- path: /DATA/AppData/$AppID/ci/socket
|
||||||
user: $PUID
|
user: $PUID
|
||||||
@@ -646,21 +741,12 @@ x-compose-app:
|
|||||||
user: $PUID
|
user: $PUID
|
||||||
group: $PGID
|
group: $PGID
|
||||||
files:
|
files:
|
||||||
# The landing page follows store updates: re-rendered on every up. None of the three holds a
|
# Each entry and its reason are in the standalone listing it comes from. All follow store
|
||||||
# dollar-brace sequence, so rendering cannot fail on an unresolved variable.
|
# updates (re-rendered on every up).
|
||||||
- path: /DATA/AppData/$AppID/site/index.html
|
- path: /DATA/AppData/$AppID/gitea/runner-config/config.yaml
|
||||||
from: site/index.html
|
from: gitea/runner-config/config.yaml
|
||||||
ensure: always
|
ensure: always
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
- path: /DATA/AppData/$AppID/site/icon.png
|
|
||||||
from: site/icon.png
|
|
||||||
ensure: always
|
|
||||||
mode: "0644"
|
|
||||||
- path: /DATA/AppData/$AppID/web/default.conf
|
|
||||||
from: web/default.conf
|
|
||||||
ensure: always
|
|
||||||
mode: "0644"
|
|
||||||
# Each entry and its reason are in the standalone listing it comes from.
|
|
||||||
- path: /DATA/AppData/$AppID/radicle/home/config.json
|
- path: /DATA/AppData/$AppID/radicle/home/config.json
|
||||||
from: radicle/home/config.json.tmpl
|
from: radicle/home/config.json.tmpl
|
||||||
ensure: always
|
ensure: always
|
||||||
@@ -674,7 +760,8 @@ x-compose-app:
|
|||||||
ensure: always
|
ensure: always
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
init:
|
init:
|
||||||
# Radicle's identity before its node starts. The CI needs nothing: it uses the node's.
|
# In order: Radicle's identity before its node starts, then Gitea's admin, then the runner
|
||||||
|
# token and the bridge's setup, which both need that admin to exist.
|
||||||
- name: radicle-identity
|
- name: radicle-identity
|
||||||
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
|
image: dirk1980/radicle-seed-node@sha256:78ccee526ad4b4f4d7c79c2323b032a7209858a48e1dc9c077272ff1660050e2
|
||||||
user: $PUID:$PGID
|
user: $PUID:$PGID
|
||||||
@@ -682,5 +769,54 @@ 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
|
||||||
webui-host: ipcr-forge-${domain}
|
- name: gitea-admin
|
||||||
|
image: gitea/gitea:1.27.3-rootless
|
||||||
|
user: $PUID:$PGID
|
||||||
|
phase: post_up
|
||||||
|
volumes:
|
||||||
|
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
|
||||||
|
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
timeout 240 sh -c 'until gitea admin user list >/dev/null 2>&1; do sleep 3; done'
|
||||||
|
gitea admin user create --admin --username gitea_admin \
|
||||||
|
--password "$APP_DEFAULT_PASSWORD" --email "$APP_EMAIL" \
|
||||||
|
--must-change-password=false \
|
||||||
|
|| echo "admin account already present, leaving it alone"
|
||||||
|
when: always
|
||||||
|
- name: gitea-runner-token
|
||||||
|
image: gitea/gitea:1.27.3-rootless
|
||||||
|
user: $PUID:$PGID
|
||||||
|
phase: post_up
|
||||||
|
network: $APP_NET
|
||||||
|
volumes:
|
||||||
|
- /DATA/AppData/$AppID/gitea/data:/var/lib/gitea
|
||||||
|
- /DATA/AppData/$AppID/gitea/config:/etc/gitea
|
||||||
|
- /DATA/AppData/$AppID/gitea/runner:/runner
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
timeout 240 sh -c 'until gitea actions generate-runner-token > /runner/token.new 2>/dev/null; do sleep 3; done'
|
||||||
|
mv /runner/token.new /runner/token
|
||||||
|
chmod 600 /runner/token
|
||||||
|
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
|
||||||
|
# The bridge's Gitea token (public-only, read:repository), the webhook secret, the system
|
||||||
|
# webhook, and the example repository. Idempotent, so on every start: it repairs the webhook.
|
||||||
|
# The admin password is used here only, never stored.
|
||||||
|
- name: forge-setup
|
||||||
|
image: ghcr.io/yundera/ipcr-forge-bridge:0.1.0
|
||||||
|
user: $PUID:$PGID
|
||||||
|
phase: post_up
|
||||||
|
network: $APP_NET
|
||||||
|
volumes:
|
||||||
|
- /DATA/AppData/$AppID/bridge/secrets:/secrets
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 ipcr-forge-bridge setup
|
||||||
|
when: always
|
||||||
|
webui-host: gitea-${domain}
|
||||||
webui-path: /
|
webui-path: /
|
||||||
|
|||||||
+121
-82
@@ -1,112 +1,148 @@
|
|||||||
# IPCR Forge — Rationale
|
# IPCR Forge — Rationale
|
||||||
|
|
||||||
IPCR Forge bundles two store apps (Radicle, IPCR), the node's own CI (`rad-actions`) and a static
|
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
|
||||||
landing page. The Radicle and IPCR services are copied verbatim, so each keeps the deviations its
|
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and
|
||||||
standalone listing already argues. This document lists them, then covers only what the bundle adds.
|
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings,
|
||||||
|
so each keeps the deviations its standalone listing already argues. This document lists them,
|
||||||
|
then covers only what the bundle adds.
|
||||||
|
|
||||||
Earlier versions built with Gitea Actions, fed by a bridge that copied every Radicle repository
|
Gitea is the source of truth. Earlier versions made Radicle the source: first with a read-only
|
||||||
into Gitea. That made two places where state could start (Gitea held a second copy of each
|
Gitea fed from it (v1), then with CI on the Radicle node alone (v2). Users found both hard to work
|
||||||
repository, with its own PRs, issues and registry) and needed a glue service to keep them in step.
|
with. See docs/forge.md.
|
||||||
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
|
||||||
|
|
||||||
**Inherited, unchanged** (the full argument is in each listing's `rationale.md`):
|
**Inherited, unchanged.** The full argument for each is in that listing's `rationale.md`.
|
||||||
|
|
||||||
| From | Deviation |
|
| From | Deviation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| Gitea | An admin account (`gitea_admin`) created with the server's default app password |
|
||||||
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
|
| Radicle | No authentication gate on `radicle-` and `radicle-api-<domain>`; host port `8776/tcp` |
|
||||||
| IPCR | `ipcr-gateway` and `ipcr-registry` run as root (no capabilities); `/etc/docker/certs.d` mounted; no authentication; ports `127.0.0.1:4767` and `4768/tcp+udp` |
|
| 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. **`rad-actions-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
1. **`forge-docker` runs `privileged`** (Docker-in-Docker), as Gitea's runner did.
|
||||||
2. **`rad-actions` mounts the Radicle node's home read-write**, including the node's key.
|
2. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
|
||||||
3. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry is.
|
key. It publishes repositories signed by that key.
|
||||||
4. **A public landing page, including the CI's pages.** `ipcr-forge-<domain>` and its `/ci/`
|
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
|
||||||
(run reports and logs) are not behind the SSO gate.
|
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
|
||||||
5. **It replaces two apps** and cannot be installed beside them: same container names, domains
|
4. **An install step that uses the admin password** to mint the bridge's token and to register
|
||||||
and host ports.
|
that webhook.
|
||||||
|
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
|
||||||
|
is.
|
||||||
|
6. **A public page.** `ipcr-forge-<domain>` is not behind the SSO gate.
|
||||||
|
7. **Public repositories are published to Radicle automatically**, and that cannot be undone.
|
||||||
|
8. **It replaces three apps** and cannot be installed beside them: it uses the same container names,
|
||||||
|
domains and host ports.
|
||||||
|
|
||||||
## Why it is necessary
|
## Why it is necessary
|
||||||
|
|
||||||
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. The two ways to give
|
**1. Privileged Docker-in-Docker.** Building an image needs a Docker daemon. A CI job can get one
|
||||||
a CI job one are the host's socket (root on the PCS for every workflow) or a daemon of its own,
|
in two ways:
|
||||||
which needs `privileged`. The second confines a build to its own container and image store. The
|
- the host's socket, which gives every workflow root on the PCS;
|
||||||
rootless variant does not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`;
|
- a daemon of its own, which needs `privileged`.
|
||||||
see Apps/Gitea/rationale.md, where it was tried first).
|
|
||||||
|
|
||||||
**2. The node's home.** The CI broker (`cib`, upstream `radicle-ci-broker`) is a client of the
|
The second keeps a build inside its own container and image store. The rootless variant does
|
||||||
node: it subscribes to the node's events over the control socket in that folder, reads repository
|
not start on this platform (`kernel.apparmor_restrict_unprivileged_userns=1`; see
|
||||||
storage to check out a commit, and writes each run's result back into the repository as a job COB,
|
Apps/Gitea/rationale.md, where it was tried first). The runner itself is a separate, unprivileged
|
||||||
which the node's key signs. This is the mount `radicle-api` already has. It is what removes the
|
container that reaches the daemon over a unix socket.
|
||||||
mirror: the build reads the commit where it lives, so there is no copy to drift.
|
|
||||||
|
|
||||||
**3. The network link.** IPCR publishes images by *pulling* them: it polls a registry and imports
|
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the
|
||||||
every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach `ipcr.localhost` (that
|
node's storage, signing its refs with the node's key, and announcing them through the node's
|
||||||
name is the host's loopback), so a workflow pushes to a staging registry instead. That registry
|
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge
|
||||||
shares the daemon's network namespace, so it is `localhost:5000` to every job — the only address
|
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
|
||||||
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. The public pages.** The landing page holds links, documentation and image names that are
|
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
|
||||||
public on IPFS anyway. `/ci/` shows `cib`'s report pages and the run logs. Radicle repositories are
|
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits
|
||||||
public by design, and the adapter refuses to run a private repository, so a log never shows code
|
on one. The list adds exactly one host name. `private` would let any user's webhook reach every
|
||||||
that is not already public. A login in front would stand between a developer and their build log.
|
container on the shared network.
|
||||||
|
|
||||||
**5. Replacing two apps.** The bundle is the same services, not a different product. Renaming every
|
**4. The admin password at install.** Gitea's token API and its system-webhook API accept only an
|
||||||
container, domain and port to coexist would break the defaults the services rely on
|
administrator signing in. The step uses the password once per start, never stores it, and
|
||||||
(`radicle-api:8080`) and the documented addresses users type.
|
leaves the running service holding nothing but a `public-only`, `read:repository` token. If the
|
||||||
|
password has been changed, the step keeps the existing token and webhook instead of failing.
|
||||||
|
|
||||||
|
**5. The network link.** IPCR publishes images by *pulling* them: it polls a registry and
|
||||||
|
imports every new tag into IPFS. Jobs run in the CI's own daemon, which cannot reach
|
||||||
|
`ipcr.localhost`, because that name is the host's loopback. So a workflow pushes to a staging
|
||||||
|
registry instead. That registry shares the daemon's network namespace, so every job sees it as
|
||||||
|
`localhost:5000`. That is the only address Docker and BuildKit accept plain HTTP for, so no TLS
|
||||||
|
or credentials are involved. `ipcr-gateway` reaches it as `forge-docker:5000` over `ci-internal`.
|
||||||
|
|
||||||
|
**6. The public page.** It holds links, documentation, image names that are public on IPFS anyway,
|
||||||
|
and the list of repositories already public on Radicle. A login in front of it would stand between
|
||||||
|
a stranger and the instructions for pulling an image.
|
||||||
|
|
||||||
|
**7. Publishing to Radicle.** That is the point of the mirror: the code of a released image stays
|
||||||
|
available without this server. Only public repositories go. A public Gitea repository is meant to
|
||||||
|
be read by anyone, and Radicle is one more place to read it.
|
||||||
|
|
||||||
|
**8. Replacing three apps.** The bundle runs the same services, not a different product. Renaming
|
||||||
|
every container, domain and port so they could coexist would break the defaults the services rely
|
||||||
|
on (`radicle-api:8080`, `gitea:3000`) and the addresses users type.
|
||||||
|
|
||||||
## Security mitigations in place
|
## Security mitigations in place
|
||||||
|
|
||||||
- **Only delegates can start a build.** `cib`'s filter (ci/ci-broker.yaml) runs CI only for changes
|
**The bridge**
|
||||||
that come from a delegate of the repository (`AnyDelegate`): release tags, the default branch and
|
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and
|
||||||
their patches. A stranger's patch is never run. This matters because a build can publish: what a
|
`read:repository`. Gitea hides every private or limited repository from it, and it can write
|
||||||
job pushes to the staging registry ends up on IPFS under the repository's IPNS name. A build
|
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
|
||||||
therefore gets exactly the trust of a release.
|
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
|
||||||
- **The privileged daemon has no network listener.** `dockerd` gets an explicit
|
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says
|
||||||
`--host=unix:///run/dind/docker.sock` and `DOCKER_TLS_CERTDIR` is empty, so the entrypoint adds no
|
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload
|
||||||
TCP socket. The unix socket sits in a folder shared only with `rad-actions`, group `$PGID`.
|
can at most cause a pointless sync. Port 8081 has no Caddy route.
|
||||||
- **`rad-actions` itself runs unprivileged:** `$PUID`, every capability dropped,
|
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
|
||||||
`no-new-privileges`, 512 MB. Whoever controls it controls the daemon (that is the point of the
|
`no-new-privileges`, 256 MB. One worker does one sync at a time.
|
||||||
socket), so it accepts no input but the node's events and a workflow from a delegate.
|
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home.
|
||||||
- **The node's key never enters a job.** Jobs run in the nested daemon from a self-contained,
|
|
||||||
depth-1 checkout that the adapter fetched; the Radicle home is not mounted into them.
|
**The CI daemon**
|
||||||
- **The staging registry is unreachable from outside.** No port, no Caddy route, on `ci-internal`
|
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
|
||||||
only (`rad-actions`, the daemon and `ipcr-gateway`). It runs as `$PUID` with no capabilities.
|
empty `DOCKER_TLS_CERTDIR`. The unix socket sits in a folder shared only with `forge-runner`,
|
||||||
- **Bounded.** One run at a time (`concurrent_adapters: 1`), 60 minutes per run, 2 GB for the daemon.
|
group `$PGID`.
|
||||||
- **The landing page cannot change anything.** `nginx-unprivileged` runs as `$PUID` with a
|
- **`forge-runner` runs unprivileged:** `$PUID`, no capabilities, `no-new-privileges`. Jobs run in
|
||||||
read-only root filesystem, no capabilities, `no-new-privileges` and 32 MB. It mounts the page,
|
the nested daemon, never on the host's.
|
||||||
its config, IPCR's `state/` and the CI's `html/`, all read-only, and serves static files only.
|
- **The staging registry is unreachable from outside.** It has no port and no Caddy route and sits
|
||||||
From `state/` it maps exactly one file (`/images.json` → `published.json`).
|
on `ci-internal` only. It runs as `$PUID` with no capabilities.
|
||||||
- **The CI is severable.** Stopping `rad-actions` and `rad-actions-docker` removes the privileged
|
- **Bounded.** One job at a time, at most 1 hour per job, 2 GB for the daemon.
|
||||||
container and leaves a working Radicle node and IPCR registry.
|
- **Severable.** Stopping `forge-runner` and `forge-docker` removes the privileged container and
|
||||||
- **Smaller than what it replaces.** Gone: an admin account with the default password, two minted
|
leaves a working Gitea, Radicle node and IPCR registry.
|
||||||
tokens, the `DAC_READ_SEARCH` capability IPCR needed to read one of them, and a second git server.
|
|
||||||
|
**Gitea**
|
||||||
|
- **Fork pull requests wait for approval.** In Gitea 1.27, a run from a user without write access
|
||||||
|
waits for "Approve and run".
|
||||||
|
- **Registration is off.** Every account is created by the admin.
|
||||||
|
- **Who can publish is stated** in the tips and on the page. Write access to any repository means
|
||||||
|
being able to publish images under the forge's name. Images from private repositories become
|
||||||
|
public on IPFS.
|
||||||
|
|
||||||
|
**The page**
|
||||||
|
- **It cannot change anything.** It is served by the bridge's own read-only HTTP handler: the
|
||||||
|
embedded page, IPCR's `published.json` (mounted read-only) and the bridge's repository list.
|
||||||
|
|
||||||
## Alternatives considered and rejected
|
## Alternatives considered and rejected
|
||||||
|
|
||||||
| Alternative | Why not |
|
| Alternative | Why not |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Keep Gitea, fed by a bridge (previous version) | Two sources of state (a second copy of each repository, with its own PRs and registry) and a glue service to keep them in step. |
|
| Radicle as the source, Gitea read-only (v1) | Users had to work in Radicle and found a read-only Gitea confusing |
|
||||||
| 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. |
|
| Radicle only, CI on the node (v2) | Not user-friendly enough: every step went through `rad` |
|
||||||
| 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. |
|
| Gitea push mirror to Radicle | Gitea cannot push to `rad://` (no `git-remote-rad`), and mirror pushes run on a timer |
|
||||||
| `cib`'s native adapter, or Ambient | Neither runs GitHub-style workflows; Ambient needs KVM. |
|
| Publishing to Radicle from a workflow job | It would put the node's key inside CI jobs |
|
||||||
| `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. |
|
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
|
||||||
| Host Docker socket for jobs | Any workflow would get root on the host. |
|
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
|
||||||
| Run patches from anyone | Their build could publish under the repository's IPNS name. Needs a separate daemon with no registry first. |
|
| Host Docker socket for jobs | Any workflow would get root on the host |
|
||||||
|
| A long-lived admin token for the bridge | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- **No job COB for tag builds.** cib 0.32.1 keys a job by the event's tip, which for an annotated
|
- **Radicle cannot unpublish.** A repository made private or deleted in Gitea is *frozen* (no
|
||||||
tag is the tag object rather than a commit, and creating that job fails (silently; the log only
|
further sync). What already reached the network stays there.
|
||||||
shows `NoJob` when the run finishes). Branch and patch builds get their job COB. Tag builds still
|
- **A deleted tag stays in Radicle's canonical `refs/tags/`.** It is removed from the node's own
|
||||||
appear in `/ci/` with their log. Upstream fix: peel the tag before creating the job.
|
namespace only.
|
||||||
- **Tags arrive as pushes.** cib sends a tag event as `event_type: push` with the tag's name in
|
- **Radicle signatures are the forge's, not the developer's.** The node is each repository's only
|
||||||
`branch`. The adapter tells them apart by looking for that name under `refs/tags/` in storage.
|
delegate.
|
||||||
- **`HasFile` matches files only**, not folders, so cib cannot pre-filter on a workflow folder; the
|
- **Image names are not tied to repositories.** A build may push any name to the staging registry.
|
||||||
adapter answers "no workflow" itself.
|
Per-repository credentials, issued by the bridge, are a planned follow-up.
|
||||||
|
|
||||||
## Data protection
|
## Data protection
|
||||||
|
|
||||||
@@ -114,12 +150,15 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
|
|||||||
|
|
||||||
| Folder | Holds |
|
| Folder | Holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `radicle/` | node identity (key pair), seeded repositories (including job COBs), Caddy config |
|
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
|
||||||
| `ci/` | `cib`'s database (`state/`), report pages and run logs (`html/`), act's action cache, the daemon's image store (`docker/`), the staging registry (`registry/`, disposable) |
|
| `radicle/` | node identity (key pair), seeded repositories, Caddy config |
|
||||||
|
| `bridge/` | `state/`: the Gitea → Radicle mapping and one bare mirror per repository (disposable); `secrets/` (0700): its Gitea token and the webhook secret |
|
||||||
|
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) |
|
||||||
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
|
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state |
|
||||||
| `site/`, `web/` | the landing page and its nginx config, re-rendered on every start |
|
|
||||||
|
|
||||||
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is
|
No user directory (`/DATA/Documents`, `Media`, …) is mounted. The one file outside `/DATA` is
|
||||||
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
|
IPCR's `/etc/docker/certs.d/ipcr.localhost:4767/ca.crt`, as in the standalone app. Identities that
|
||||||
matter on the network (Radicle's node key, Kubo's node key and IPNS keys) survive uninstall and
|
matter on the network survive uninstall and reinstall, so published repositories and image names
|
||||||
reinstall, so published repositories and image names stay valid.
|
stay valid:
|
||||||
|
- Radicle's node key;
|
||||||
|
- Kubo's node key and IPNS keys.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# act_runner configuration for IPCR Forge. Only the settings that differ from upstream's defaults
|
||||||
|
# are written out — `act_runner generate-config` prints the full set. From Apps/Gitea's, except
|
||||||
|
# where marked "Forge:".
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
# The file the runner's own registration is kept in, relative to /data.
|
||||||
|
file: .runner
|
||||||
|
# One job at a time. A PCS shares its CPU with everything else installed on
|
||||||
|
# it, and an image build is the least polite thing on the box.
|
||||||
|
capacity: 1
|
||||||
|
timeout: 1h
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
# Overrides the labels stored at registration (.runner). Gitea's own job images, which carry
|
||||||
|
# node, bash, git and the docker CLI with buildx, so marketplace actions (actions/checkout,
|
||||||
|
# docker/build-push-action, ...) run as they do on GitHub.
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
|
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||||
|
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
# The cache server binds an address inside the runner container, which the
|
||||||
|
# job containers — created by the separate Docker daemon — cannot route to.
|
||||||
|
# Left on, every `actions/cache` step fails noisily instead of being skipped.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
container:
|
||||||
|
# Forge: jobs run on the daemon's host network, which is the forge-docker container's own
|
||||||
|
# namespace — where the staging registry listens as localhost:5000. Plain HTTP to localhost is
|
||||||
|
# the one thing Docker and BuildKit accept without TLS, so a workflow needs no login and no
|
||||||
|
# certificate. Jobs still reach the internet (actions, base images) and Gitea through its public
|
||||||
|
# address.
|
||||||
|
network: host
|
||||||
|
privileged: false
|
||||||
|
# Empty: the runner's own daemon (DOCKER_HOST) is mounted into each job, so `docker build`
|
||||||
|
# and buildx work in a job, against the same daemon (and the same localhost:5000).
|
||||||
|
docker_host: ""
|
||||||
|
force_pull: false
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json and the CI broker's
|
|
||||||
# report pages (and the adapter's run logs) under /ci/.
|
|
||||||
# No nginx variables on purpose: this file is also safe to render as a Maison template.
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_tokens off;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location = /images.json {
|
|
||||||
alias /srv/ipcr-state/published.json;
|
|
||||||
default_type application/json;
|
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
}
|
|
||||||
|
|
||||||
# cib rewrites these pages as runs progress; logs are plain text.
|
|
||||||
location /ci/ {
|
|
||||||
alias /srv/ci/;
|
|
||||||
index index.html;
|
|
||||||
types { text/html html; text/css css; application/json json; text/plain log; }
|
|
||||||
charset utf-8;
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only the page and its icon; nothing else under the state directory is reachable.
|
|
||||||
location / {
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+138
-147
@@ -1,185 +1,176 @@
|
|||||||
# IPCR Forge — design notes
|
# IPCR Forge — design notes
|
||||||
|
|
||||||
How the forge in [Apps/IPCR-Forge/](../Apps/IPCR-Forge/) goes from `git push` to `docker pull`, why it
|
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
|
is shaped this way, and what was learned building it. The listing's own security argument is in
|
||||||
argument is in [rationale.md](../Apps/IPCR-Forge/rationale.md).
|
[rationale.md](../Apps/IPCR-Forge/rationale.md).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
A decentralised forge that works out of the box: **Radicle is the single source of truth**, builds
|
A forge people actually want to use, whose releases do not depend on it staying online:
|
||||||
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
|
- **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there.
|
||||||
|
Developers use plain git and a familiar web UI.
|
||||||
|
- **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR
|
||||||
|
can pull them from any peer that holds them.
|
||||||
|
- **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network,
|
||||||
|
so it can be cloned without this server.
|
||||||
|
|
||||||
The first version built with Gitea Actions. A bridge service copied every Radicle repository into
|
## History: three versions
|
||||||
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
|
| Version | Source of truth | CI | Feedback |
|
||||||
own PRs, issues and registry. A copy that is only ever *derived* is a cache; one where things can
|
| --- | --- | --- | --- |
|
||||||
also *start* will desync.
|
| v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing |
|
||||||
- **A glue service** (the bridge) to keep the two in step, plus its credentials: an admin account,
|
| v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` |
|
||||||
two minted tokens, and a capability (`DAC_READ_SEARCH`) IPCR needed only to read one of them.
|
| **v3** (this) | **Gitea** | **Gitea Actions** | — |
|
||||||
- What Gitea actually contributed was narrow: running Actions YAML, a web view of runs, and a
|
|
||||||
staging registry. None of those needs a forge.
|
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the
|
||||||
|
CI's own Docker daemon (no tokens, no registry credentials). The bridge is v1's glue reversed and
|
||||||
|
rewritten in Go ([bridge/](../bridge/)).
|
||||||
|
|
||||||
|
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply
|
||||||
|
here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
|
||||||
|
opened on the Radicle side are not read.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
developer: git push rad main v1.2.3 (tag signed by a repository delegate)
|
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
radicle-node ──node events──▶ rad-actions container
|
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge)
|
||||||
├─ cib (radicle-ci-broker 0.32.1, unmodified): filter, queue, reports, job COBs
|
│ · git fetch from Gitea
|
||||||
└─ rad-actions adapter (ci/adapter, ours):
|
│ Actions · rad init (first time)
|
||||||
· fetch the commit from the node's storage (depth 1)
|
▼ · git push rad (signed by the node)
|
||||||
· apply GitHub's `on:` filters
|
forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼
|
||||||
· run the workflow with act 0.2.89 (unmodified)
|
jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network
|
||||||
│ DOCKER_HOST = unix socket
|
forge-registry (registry:3, shares dind's network namespace)
|
||||||
▼
|
▲ polled every minute over ci-internal
|
||||||
rad-actions-docker (dind, privileged, no TCP listener)
|
ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key
|
||||||
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
|
anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner>/<repo>:1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
- **No mirror.** The build reads the commit where it lives. Results go back into the repository as
|
The two legs do not depend on each other: a repository can be built without being mirrored
|
||||||
job COBs (`xyz.radworks.job`) signed by the node (see limitations: not for tags yet).
|
(private repositories), and mirrored without being built (no workflow).
|
||||||
- **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
|
## The bridge
|
||||||
|
|
||||||
- `cib` runs CI only for changes from a **delegate** of the repository (`!AnyDelegate`): release
|
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`.
|
||||||
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.
|
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
|
||||||
- The node's key (which signs job COBs) never enters a job: jobs get a self-contained checkout.
|
`gitea_admin` with the default app password and does four things:
|
||||||
- The adapter refuses private repositories, because run logs are public.
|
- mints the daemon's token, `public-only` + `read:repository`;
|
||||||
- To run outsiders' patches safely, they would need a separate daemon with no route to the staging
|
- generates the webhook secret;
|
||||||
registry. Not built.
|
- creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`),
|
||||||
|
matched by URL so it is never duplicated;
|
||||||
|
- creates the example `gitea_admin/ipcr-hello`.
|
||||||
|
- **`serve`**:
|
||||||
|
- `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the
|
||||||
|
repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds.
|
||||||
|
- One worker, with a coalescing queue: a burst of deliveries is one sync.
|
||||||
|
- A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames
|
||||||
|
and visibility changes, plus any lost delivery.
|
||||||
|
- `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future
|
||||||
|
admin pages, such as Radicle settings.
|
||||||
|
|
||||||
|
### Mirror semantics
|
||||||
|
|
||||||
|
| Gitea | Radicle |
|
||||||
|
| --- | --- |
|
||||||
|
| public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical |
|
||||||
|
| push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync <rid> --announce` |
|
||||||
|
| tag created (git or web UI) | same; becomes canonical `refs/tags/<tag>` |
|
||||||
|
| tag deleted | removed from the node's namespace; **the canonical `refs/tags/<tag>` stays** (see findings) |
|
||||||
|
| default branch changed | `rad id update … defaultBranch` |
|
||||||
|
| rename / transfer | same RID (state is keyed by Gitea's repository ID) |
|
||||||
|
| made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be |
|
||||||
|
| private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) |
|
||||||
|
| empty | waits for its first push |
|
||||||
|
|
||||||
|
The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The
|
||||||
|
code checks visibility again anyway.
|
||||||
|
|
||||||
|
## Trust
|
||||||
|
|
||||||
|
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a
|
||||||
|
distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
|
||||||
|
[naming.md](naming.md) is weaker for it.
|
||||||
|
- **Anyone who can push to a repository can publish images.** A job can push any name to the
|
||||||
|
unauthenticated `localhost:5000`, and IPCR publishes whatever it finds there under the forge's IPNS
|
||||||
|
key, including images built from private repositories. Registration is off, so every user was
|
||||||
|
created by the admin.
|
||||||
|
- **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27.
|
||||||
|
- The page, the tips and `rationale.md` say all of this plainly.
|
||||||
|
- **Follow-up:** per-repository push credentials, with the bridge acting as a registry token
|
||||||
|
server, would tie an image name to its repository.
|
||||||
|
|
||||||
## Workflow contract
|
## Workflow contract
|
||||||
|
|
||||||
A repository opts in by committing a workflow under `.github/workflows/` (`.gitea/` and
|
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
|
||||||
`.forgejo/` are also read). The template on the landing page:
|
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml):
|
||||||
|
|
||||||
```yaml
|
- `images: localhost:5000/${{ github.repository }}`: the staging registry. No login, no secret.
|
||||||
on:
|
`docker/metadata-action` lowercases the name.
|
||||||
push:
|
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
|
||||||
tags: ['v*']
|
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
|
||||||
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
|
## Findings
|
||||||
|
|
||||||
Each of these silently produced the wrong result rather than an error.
|
Carried over from v1 and v2; each one silently produced the wrong result rather than an error.
|
||||||
|
|
||||||
### act
|
1. **Jobs must share the daemon's network namespace.** `localhost:5000` is the registry only on
|
||||||
|
forge-docker's own network, and Docker/BuildKit accept plain HTTP for `localhost` only. The runner
|
||||||
|
config sets `container.network: host`. As a consequence, compose service names do not resolve
|
||||||
|
inside jobs, so `GITEA_INSTANCE_URL` is Gitea's public address.
|
||||||
|
2. **`docker/build-push-action` defaults to a Git context** (`<server>/<repo>.git#<ref>`), which needs
|
||||||
|
credentials here. Hence `context: .`.
|
||||||
|
3. **buildx's builder container** does not share the job's network. `driver-opts: network=host` puts
|
||||||
|
it on the daemon's, where `localhost:5000` is the registry.
|
||||||
|
4. **act_runner's `run.sh` reads the registration token once** and gives up after 10 tries. The token
|
||||||
|
is minted by an install step that runs after the stack is up, so the compose entrypoint waits for
|
||||||
|
it first.
|
||||||
|
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without
|
||||||
|
one, a tag lives under its pusher's namespace, where `rad clone` and the explorer do not show it.
|
||||||
|
The bridge adds the rule right after `rad init`.
|
||||||
|
|
||||||
1. **act does not apply `on:` branch/tag filters.** A workflow with `on: push: tags: ['v*']` ran for
|
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
|
||||||
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
|
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge
|
||||||
|
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote.
|
||||||
|
7. **A deleted tag stays canonical.** `git push --prune` removes it from the node's namespace, but
|
||||||
|
`refs/tags/<tag>` keeps the last target. Tags are meant to be immutable, so this was left alone.
|
||||||
|
8. **Gitea's default webhook allow-list (`external`) blocks the bridge.** It is on the server's
|
||||||
|
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container.
|
||||||
|
`private` would let any user's webhook reach every container on the shared network.
|
||||||
|
9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
|
||||||
|
`--rid` flag.
|
||||||
|
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with
|
||||||
|
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
|
||||||
|
network.
|
||||||
|
|
||||||
5. **`docker/metadata-action` calls the GitHub REST API** (`GET /repos/{owner}/{repo}`) and fails
|
## Verified
|
||||||
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
|
Local integration (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker
|
||||||
|
network):
|
||||||
|
|
||||||
8. **Tags arrive as pushes.** A tag event reaches the adapter as `event_type: "push"` with the tag's
|
- `setup` run twice leaves exactly one system hook.
|
||||||
name in `branch` (src/msg.rs), so the first tag build ran as if `v1.1.1` were a branch. The
|
- `ipcr-hello` was created and mirrored with the crefs rule in its identity.
|
||||||
adapter looks the name up in storage (`refs/namespaces/*/refs/tags/<name>`).
|
- A tag created through Gitea's API was canonical on Radicle within seconds, through the webhook.
|
||||||
9. **`!HasFile` matches files only,** never directories (it checks `into_blob()`), despite its doc
|
- A private repository never appeared.
|
||||||
comment. A filter on `.github/workflows` therefore never matched and nothing ran. It was dropped;
|
- A public repository made private was frozen.
|
||||||
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
|
The full chain on a PCS (runner → staging → IPCR → pull from a second server) is in
|
||||||
|
[Open items](#open-items) until it is run on a test box.
|
||||||
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
|
## Open items
|
||||||
|
|
||||||
- **Publish the images.** `ghcr.io/yundera/rad-actions` (workflow `rad-actions.yml`, tag
|
- **Run the full chain on a test PCS.** Check that the runner registers, that jobs see the daemon
|
||||||
`rad-actions-v*`) and `ghcr.io/yundera/ipcr:1.2.0` are so far built locally only.
|
(`docker_host: ""` mounts its socket into each job), that a `v*` tag on `ipcr-hello` builds and
|
||||||
- **Job COBs for tags** (finding 10): an upstream patch to cib.
|
imports, and that the image pulls from a second server.
|
||||||
- **Patches from non-delegates:** a second, registry-less daemon.
|
- **Publish the images**: `ghcr.io/yundera/ipcr:1.2.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.1.0`
|
||||||
- **Warm cache.** The first build pulls `catthehacker/ubuntu:act-latest` (~1.5 GB) and clones the
|
(workflows `image.yml` and `bridge.yml`, tag `bridge-v0.1.0`).
|
||||||
actions it uses; later builds reuse both (`ci/docker`, `ci/cache`).
|
- **Per-repository image names:** the bridge as a registry token server (see Trust).
|
||||||
- **Path filters** (`on.push.paths`) are not applied: a workflow with only `paths:` runs on every
|
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
|
||||||
matching event.
|
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
|
||||||
- **Store install.** Tested as a hand install that mimics Maison (compose, `.env`, `.seed/`), not yet
|
- **Store install:** test through the store, not only as a hand install.
|
||||||
through the store.
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and
|
||||||
|
# the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
|
||||||
|
# Build context: this folder (bridge/).
|
||||||
|
FROM golang:1.24-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge .
|
||||||
|
|
||||||
|
# Radicle's official static (musl) release, checked against its published digest.
|
||||||
|
FROM alpine:3.22 AS radicle
|
||||||
|
ARG RADICLE_VERSION=1.10.1
|
||||||
|
ARG RADICLE_SHA256=9354f7f6241a88ddca1d41a080aa6337ddfc5dc682b8de78ab6d418b5782225a
|
||||||
|
# Retried: release downloads intermittently fail.
|
||||||
|
RUN apk add --no-cache xz \
|
||||||
|
&& url="https://files.radicle.dev/releases/${RADICLE_VERSION}/radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl.tar.xz" \
|
||||||
|
&& for i in 1 2 3 4 5; do wget -qO /tmp/radicle.tar.xz "$url" && break; sleep $((i * 5)); done \
|
||||||
|
&& echo "${RADICLE_SHA256} /tmp/radicle.tar.xz" | sha256sum -c - \
|
||||||
|
&& mkdir /out && tar -xJf /tmp/radicle.tar.xz -C /out --strip-components=2 \
|
||||||
|
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/rad" \
|
||||||
|
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/git-remote-rad"
|
||||||
|
|
||||||
|
FROM alpine:3.22
|
||||||
|
ARG VERSION=dev
|
||||||
|
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \
|
||||||
|
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \
|
||||||
|
org.opencontainers.image.source="https://github.com/Yundera/ipcr" \
|
||||||
|
org.opencontainers.image.version="$VERSION"
|
||||||
|
RUN apk add --no-cache ca-certificates git \
|
||||||
|
# Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see
|
||||||
|
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
|
||||||
|
&& git config --system safe.directory '*'
|
||||||
|
COPY --from=radicle /out/ /usr/local/bin/
|
||||||
|
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge
|
||||||
|
ENV RAD_HOME=/radicle-home \
|
||||||
|
HOME=/tmp
|
||||||
|
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image.
|
||||||
|
CMD ["ipcr-forge-bridge", "serve"]
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sign(secret, body string) string {
|
||||||
|
m := hmac.New(sha256.New, []byte(secret))
|
||||||
|
m.Write([]byte(body))
|
||||||
|
return hex.EncodeToString(m.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSignature(t *testing.T) {
|
||||||
|
body := `{"repository":{"id":7}}`
|
||||||
|
if !validSignature([]byte("s3cret"), []byte(body), sign("s3cret", body)) {
|
||||||
|
t.Error("good signature rejected")
|
||||||
|
}
|
||||||
|
for _, sig := range []string{"", "zz", sign("other", body), sign("s3cret", body+" ")} {
|
||||||
|
if validSignature([]byte("s3cret"), []byte(body), sig) {
|
||||||
|
t.Errorf("bad signature %q accepted", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHook(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretFile := filepath.Join(dir, "hook-secret")
|
||||||
|
var got []int64
|
||||||
|
h := &hookHandler{secretFile: secretFile, enqueue: func(id int64) { got = append(got, id) }}
|
||||||
|
post := func(body, sig string) int {
|
||||||
|
r := httptest.NewRequest("POST", "/hooks/gitea", strings.NewReader(body))
|
||||||
|
r.Header.Set("X-Gitea-Signature", sig)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
body := `{"ref":"refs/tags/v1.0.0","repository":{"id":42,"full_name":"a/b"}}`
|
||||||
|
if c := post(body, sign("k", body)); c != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("no secret yet: %d", c)
|
||||||
|
}
|
||||||
|
os.WriteFile(secretFile, []byte("k\n"), 0o600)
|
||||||
|
if c := post(body, sign("x", body)); c != http.StatusUnauthorized {
|
||||||
|
t.Errorf("bad signature: %d", c)
|
||||||
|
}
|
||||||
|
if c := post(body, sign("k", body)); c != http.StatusAccepted {
|
||||||
|
t.Errorf("good delivery: %d", c)
|
||||||
|
}
|
||||||
|
if c := post(`{"sender":{}}`, sign("k", `{"sender":{}}`)); c != http.StatusNoContent {
|
||||||
|
t.Errorf("no repository: %d", c)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != 42 {
|
||||||
|
t.Errorf("enqueued %v, want [42]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueCoalesces(t *testing.T) {
|
||||||
|
q := newQueue()
|
||||||
|
for _, id := range []int64{3, 1, 3, 3, 2, 1} {
|
||||||
|
q.add(id)
|
||||||
|
}
|
||||||
|
var order []int64
|
||||||
|
for {
|
||||||
|
id, ok := q.next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
order = append(order, id)
|
||||||
|
}
|
||||||
|
if len(order) != 3 || order[0] != 3 || order[1] != 1 || order[2] != 2 {
|
||||||
|
t.Errorf("order %v, want [3 1 2]", order)
|
||||||
|
}
|
||||||
|
q.add(3) // popped, so it may be queued again
|
||||||
|
if id, ok := q.next(); !ok || id != 3 {
|
||||||
|
t.Error("re-adding a popped repository")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublic(t *testing.T) {
|
||||||
|
r := repo{}
|
||||||
|
if !r.public() {
|
||||||
|
t.Error("plain repository")
|
||||||
|
}
|
||||||
|
for _, f := range []func(*repo){
|
||||||
|
func(r *repo) { r.Private = true },
|
||||||
|
func(r *repo) { r.Internal = true },
|
||||||
|
func(r *repo) { r.Owner.Visibility = "limited" },
|
||||||
|
func(r *repo) { r.Owner.Visibility = "private" },
|
||||||
|
} {
|
||||||
|
r := repo{}
|
||||||
|
f(&r)
|
||||||
|
if r.public() {
|
||||||
|
t.Errorf("%+v counted as public", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRIDFromURL(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o\n": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
|
||||||
|
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o/z6MkppQoVh2QKyQGkPzGgEaqZcXmbeBVhZseQ2cgxuMzA5gR": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
|
||||||
|
"https://example.com/x.git": "",
|
||||||
|
"": "",
|
||||||
|
} {
|
||||||
|
if got := ridFromURL(in); got != want {
|
||||||
|
t.Errorf("ridFromURL(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := ridRe.FindString("Your Repository ID (RID) is rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o\nYou can"); got != "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o" {
|
||||||
|
t.Errorf("ridRe: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushedRefs(t *testing.T) {
|
||||||
|
out := "To rad://z2pn/z6Mk\n" +
|
||||||
|
"=\trefs/heads/main:refs/heads/main\t[up to date]\n" +
|
||||||
|
"*\trefs/tags/v1.0.0:refs/tags/v1.0.0\t[new tag]\n" +
|
||||||
|
"-\t:refs/heads/old\t[deleted]\n" +
|
||||||
|
"+\trefs/heads/dev:refs/heads/dev\t1111111...2222222 (forced update)\n" +
|
||||||
|
"Done\n"
|
||||||
|
got := strings.Join(pushedRefs(out), "; ")
|
||||||
|
want := "tags/v1.0.0 [new tag]; old [deleted]; dev 1111111...2222222 (forced update)"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "repos.json")
|
||||||
|
s, err := loadState(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.known(5) || s.get(5).ID != 5 {
|
||||||
|
t.Fatal("empty state")
|
||||||
|
}
|
||||||
|
s.put(repoState{ID: 5, FullName: "b/two", RID: "rad:z2", State: stSynced, Refs: "abc", Crefs: true})
|
||||||
|
s.put(repoState{ID: 9, FullName: "a/one", State: stWaiting})
|
||||||
|
s2, err := loadState(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := s2.get(5); r.RID != "rad:z2" || r.Refs != "abc" || !r.Crefs || r.State != stSynced {
|
||||||
|
t.Errorf("reloaded %+v", r)
|
||||||
|
}
|
||||||
|
l := s2.list()
|
||||||
|
if len(l) != 2 || l[0].FullName != "a/one" || l[1].Refs != "" {
|
||||||
|
t.Errorf("list %+v", l)
|
||||||
|
}
|
||||||
|
if ids := s2.ids(); len(ids) != 2 || ids[0] != 5 || ids[1] != 9 {
|
||||||
|
t.Errorf("ids %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExampleEmbedded(t *testing.T) {
|
||||||
|
for _, p := range []string{"example/Dockerfile", "example/.gitea/workflows/build.yml", "example/README.md"} {
|
||||||
|
if _, err := exampleFiles.ReadFile(p); err != nil {
|
||||||
|
t.Errorf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
|
||||||
|
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") {
|
||||||
|
t.Error("example workflow lost its staging registry lines")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeb(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, _ := loadState(filepath.Join(dir, "repos.json"))
|
||||||
|
h := webHandler(st, filepath.Join(dir, "published.json"))
|
||||||
|
get := func(p string) (int, string) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
|
||||||
|
return w.Code, w.Body.String()
|
||||||
|
}
|
||||||
|
if c, b := get("/"); c != 200 || !strings.Contains(b, "IPCR Forge") {
|
||||||
|
t.Errorf("/: %d", c)
|
||||||
|
}
|
||||||
|
if c, _ := get("/images.json"); c != 404 {
|
||||||
|
t.Errorf("/images.json before anything is published: %d", c)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(dir, "published.json"), []byte(`{"images":{}}`), 0o644)
|
||||||
|
if c, b := get("/images.json"); c != 200 || b != `{"images":{}}` {
|
||||||
|
t.Errorf("/images.json: %d %q", c, b)
|
||||||
|
}
|
||||||
|
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
|
||||||
|
t.Errorf("/repos.json: %d %q", c, b)
|
||||||
|
}
|
||||||
|
if c, _ := get("/nothing"); c != 404 {
|
||||||
|
t.Errorf("/nothing: %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Builds the Dockerfile at the root for every v* tag and pushes it to this forge's staging registry.
|
||||||
|
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
|
||||||
|
# lists the exact `image:` line to use.
|
||||||
|
#
|
||||||
|
# Differences from a GitHub-hosted workflow, both needed on this forge:
|
||||||
|
# images: localhost:5000/… the staging registry (no login, no secret)
|
||||||
|
# network=host so buildx's builder can reach that registry
|
||||||
|
name: image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: localhost:5000/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# A one-page web server: busybox httpd on port 8080, as an unprivileged user.
|
||||||
|
FROM busybox:1.37
|
||||||
|
COPY index.html /www/index.html
|
||||||
|
USER 1000:1000
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ipcr-hello
|
||||||
|
|
||||||
|
The IPCR Forge example. A tag on this repository builds its image, IPCR publishes it on IPFS,
|
||||||
|
and the repository is mirrored to Radicle.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
1. **Tag a release.** In Gitea: *Tags → New tag* (or *Releases → New release*), name it `v1.0.0`
|
||||||
|
and target `main`. From a clone it works too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Watch the build** under *Actions*. The first build on a new server takes a few minutes more,
|
||||||
|
while it downloads its build images.
|
||||||
|
|
||||||
|
3. **Find the image** on the forge's page (`ipcr-forge-<your domain>`, *Published images*), about
|
||||||
|
a minute after the build finishes. Then on this server or on any other running IPCR:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
hello:
|
||||||
|
image: ipcr.localhost:4767/ipns/<forge name>/gitea_admin/ipcr-hello:1.0.0
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What makes it work
|
||||||
|
|
||||||
|
- [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) pushes to `localhost:5000`, the
|
||||||
|
forge's staging registry. IPCR imports every tag it finds there.
|
||||||
|
- [`Dockerfile`](Dockerfile) is an ordinary Dockerfile. Anything that builds works.
|
||||||
|
|
||||||
|
Copy the workflow into any public or private repository on this forge to publish it the same way.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ipcr-hello</title></head>
|
||||||
|
<body style="font: 18px/1.6 system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em">
|
||||||
|
<h1>Hello from IPFS</h1>
|
||||||
|
<p>This page is served by a container whose image was built by IPCR Forge and pulled from IPFS
|
||||||
|
through IPCR.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitea is a minimal Gitea API client. The daemon authenticates with the token in tokenFile (read
|
||||||
|
// on every call: the setup step may write it after the service starts); the setup step with the
|
||||||
|
// admin's password instead (Gitea's token API accepts nothing else).
|
||||||
|
type gitea struct {
|
||||||
|
base string
|
||||||
|
owner string
|
||||||
|
tokenFile string
|
||||||
|
password string // setup only
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitea(base, owner, tokenFile string) *gitea {
|
||||||
|
return &gitea{base: strings.TrimRight(base, "/"), owner: owner, tokenFile: tokenFile,
|
||||||
|
http: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gitea) token() string {
|
||||||
|
b, _ := os.ReadFile(g.tokenFile)
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gitea) hasToken() bool { return g.token() != "" }
|
||||||
|
|
||||||
|
// gitAuth is the header git sends to Gitea's HTTP endpoint: basic auth with the token as password,
|
||||||
|
// passed with http.extraHeader so it never appears in a URL, `ps` or a log.
|
||||||
|
func (g *gitea) gitAuth() string {
|
||||||
|
return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(g.owner+":"+g.token()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// call sends a request and decodes a JSON answer into out (when non-nil and the status is 2xx).
|
||||||
|
// It returns the status; a transport error is returned as an error with status 0.
|
||||||
|
func (g *gitea) call(method, path string, body, out any) (int, error) {
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, g.base+"/api/v1"+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if g.password != "" {
|
||||||
|
req.SetBasicAuth(g.owner, g.password)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Authorization", "token "+g.token())
|
||||||
|
}
|
||||||
|
resp, err := g.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
msg := strings.TrimSpace(string(data))
|
||||||
|
if len(msg) > 300 {
|
||||||
|
msg = msg[:300]
|
||||||
|
}
|
||||||
|
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, msg)
|
||||||
|
}
|
||||||
|
if out != nil && len(data) > 0 {
|
||||||
|
if err := json.Unmarshal(data, out); err != nil {
|
||||||
|
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type repo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Internal bool `json:"internal"`
|
||||||
|
Fork bool `json:"fork"`
|
||||||
|
Mirror bool `json:"mirror"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Owner struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
// public | limited | private. Only a public owner's repositories are public.
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
} `json:"owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// public reports whether the repository may leave this server. The token is `public-only`, so
|
||||||
|
// Gitea already hides everything else; this is the second lock on the same door.
|
||||||
|
func (r *repo) public() bool {
|
||||||
|
return !r.Private && !r.Internal && (r.Owner.Visibility == "" || r.Owner.Visibility == "public")
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicRepos lists every repository the token can see.
|
||||||
|
func (g *gitea) publicRepos() ([]repo, error) {
|
||||||
|
var all []repo
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
var res struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data []repo `json:"data"`
|
||||||
|
}
|
||||||
|
q := url.Values{"limit": {"50"}, "page": {fmt.Sprint(page)}}
|
||||||
|
if _, err := g.call("GET", "/repos/search?"+q.Encode(), nil, &res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, res.Data...)
|
||||||
|
if len(res.Data) < 50 {
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoByID returns the repository, or nil when the token cannot see it (private, deleted).
|
||||||
|
func (g *gitea) repoByID(id int64) (*repo, error) {
|
||||||
|
var r repo
|
||||||
|
code, err := g.call("GET", fmt.Sprintf("/repositories/%d", id), nil, &r)
|
||||||
|
if code == http.StatusNotFound || code == http.StatusForbidden {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module ipcr-forge-bridge
|
||||||
|
|
||||||
|
go 1.24
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hookHandler receives Gitea's system webhook. It only learns *which* repository changed: the sync
|
||||||
|
// itself re-reads everything from Gitea, so a payload is never trusted for content, and a burst of
|
||||||
|
// deliveries for one repository collapses into one sync.
|
||||||
|
type hookHandler struct {
|
||||||
|
secretFile string
|
||||||
|
enqueue func(id int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *hookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
secret, _ := os.ReadFile(h.secretFile)
|
||||||
|
if len(strings.TrimSpace(string(secret))) == 0 {
|
||||||
|
http.Error(w, "not configured yet", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validSignature([]byte(strings.TrimSpace(string(secret))), body, r.Header.Get("X-Gitea-Signature")) {
|
||||||
|
log.Printf("hook: bad signature from %s (event %q)", r.RemoteAddr, r.Header.Get("X-Gitea-Event"))
|
||||||
|
http.Error(w, "bad signature", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Repository *struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"repository"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil || p.Repository == nil || p.Repository.ID == 0 {
|
||||||
|
// Signed, but about no repository (a user or organisation event): nothing to do.
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.enqueue(p.Repository.ID)
|
||||||
|
// Answered at once: Gitea gives a delivery 5 seconds, a sync can take minutes.
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSignature checks Gitea's X-Gitea-Signature: hex HMAC-SHA256 of the raw body, no prefix.
|
||||||
|
func validSignature(secret, body []byte, sig string) bool {
|
||||||
|
got, err := hex.DecodeString(strings.TrimSpace(sig))
|
||||||
|
if err != nil || len(got) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write(body)
|
||||||
|
return hmac.Equal(got, mac.Sum(nil))
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
|
||||||
|
// is replicated, and this service keeps the second following the first. One way only.
|
||||||
|
//
|
||||||
|
// serve (default) webhook receiver + mirror worker + the forge's page
|
||||||
|
// setup install step: Gitea token, system webhook, example repository (idempotent)
|
||||||
|
// health exit 0 when `serve` answers, for the container healthcheck
|
||||||
|
//
|
||||||
|
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
|
||||||
|
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
|
||||||
|
// Each one is `rad init`ed by this node on first sight (the node's key is its only delegate) and
|
||||||
|
// then receives its branches and tags with `git push rad`. A repository that turns private or is
|
||||||
|
// deleted is frozen: no further sync, and nothing is taken back from the network (it cannot be).
|
||||||
|
//
|
||||||
|
// Triggers: Gitea's system webhook (push, create, delete, repository) for latency, and a full
|
||||||
|
// reconcile every RECONCILE for whatever a lost delivery, a rename or a visibility change missed.
|
||||||
|
//
|
||||||
|
// Environment:
|
||||||
|
//
|
||||||
|
// GITEA_URL Gitea, internal address (default http://gitea:3000)
|
||||||
|
// GITEA_OWNER the admin account (default gitea_admin)
|
||||||
|
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
|
||||||
|
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
|
||||||
|
// STATE_DIR repos.json and the bare mirrors (default /bridge)
|
||||||
|
// RAD_HOME the node's Radicle home (default /radicle-home)
|
||||||
|
// WEB_LISTEN the page (default :8080)
|
||||||
|
// HOOK_LISTEN the webhook, internal only (default :8081)
|
||||||
|
// HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea)
|
||||||
|
// EXAMPLE_REPO setup only: example repository to create under GITEA_OWNER; empty = none
|
||||||
|
// (default ipcr-hello)
|
||||||
|
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
|
||||||
|
// (default /srv/ipcr-state/published.json)
|
||||||
|
// RECONCILE full pass interval (default 10m)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func env(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||||
|
cmd := "serve"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
cmd = os.Args[1]
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch cmd {
|
||||||
|
case "serve":
|
||||||
|
err = serve()
|
||||||
|
case "setup":
|
||||||
|
err = setup()
|
||||||
|
case "health":
|
||||||
|
err = health()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func health() error {
|
||||||
|
c := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
r, err := c.Get("http://127.0.0.1" + env("WEB_LISTEN", ":8080") + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Body.Close()
|
||||||
|
if r.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("healthz: %s", r.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve() error {
|
||||||
|
every, err := time.ParseDuration(env("RECONCILE", "10m"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RECONCILE: %w", err)
|
||||||
|
}
|
||||||
|
stateDir := env("STATE_DIR", "/bridge")
|
||||||
|
st, err := loadState(stateDir + "/repos.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secrets := env("SECRETS_DIR", "/secrets")
|
||||||
|
m := &mirror{
|
||||||
|
gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"),
|
||||||
|
state: st,
|
||||||
|
repoDir: stateDir + "/repos",
|
||||||
|
radHome: env("RAD_HOME", "/radicle-home"),
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := newQueue()
|
||||||
|
go q.run(m.sync)
|
||||||
|
go reconcileLoop(m, q, every)
|
||||||
|
|
||||||
|
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
|
||||||
|
hmux := http.NewServeMux()
|
||||||
|
hmux.Handle("POST /hooks/gitea", hook)
|
||||||
|
go func() {
|
||||||
|
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
|
||||||
|
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
|
||||||
|
// that turned private or was deleted is noticed and frozen). The first pass runs at start-up.
|
||||||
|
func reconcileLoop(m *mirror, q *queue, every time.Duration) {
|
||||||
|
for {
|
||||||
|
if !m.gitea.hasToken() {
|
||||||
|
// The setup step runs after the stack is up, so on a first install this is normal.
|
||||||
|
log.Printf("reconcile: waiting for the Gitea token (written by the setup step)")
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repos, err := m.gitea.publicRepos()
|
||||||
|
if err != nil {
|
||||||
|
// Gitea restarting, typically: try again soon rather than in a full interval.
|
||||||
|
log.Printf("reconcile: %v", err)
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, r := range repos {
|
||||||
|
seen[r.ID] = true
|
||||||
|
q.add(r.ID)
|
||||||
|
}
|
||||||
|
for _, id := range m.state.ids() {
|
||||||
|
if !seen[id] {
|
||||||
|
q.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(every)
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What is copied, both ways of the bare mirror: branches and tags. Not refs/pull/* (Gitea's PR
|
||||||
|
// heads), not notes or anything else. Branches and tags are forced: Gitea is the truth, and a
|
||||||
|
// force push or a moved tag there is mirrored as such. --prune makes deletions follow too.
|
||||||
|
var refspecs = []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
|
||||||
|
|
||||||
|
type mirror struct {
|
||||||
|
gitea *gitea
|
||||||
|
state *state
|
||||||
|
repoDir string // one bare repository per Gitea repository: <id>.git
|
||||||
|
radHome string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
|
||||||
|
func (m *mirror) sync(id int64) {
|
||||||
|
prev := m.state.get(id)
|
||||||
|
r, err := m.gitea.repoByID(id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sync %d: %v", id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r == nil || !r.public() {
|
||||||
|
// Private now, or deleted. Only a repository that was mirrored has anything to record.
|
||||||
|
if m.state.known(id) && prev.State != stFrozen {
|
||||||
|
prev.State = stFrozen
|
||||||
|
prev.Error = "no longer public in Gitea: not synced any more (what was published stays on the Radicle network)"
|
||||||
|
m.save(prev)
|
||||||
|
log.Printf("sync %s: frozen, no longer public", prev.FullName)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Fork || r.Mirror {
|
||||||
|
// Did not originate here: a fork's upstream or a mirror's source is the one to publish.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur := prev
|
||||||
|
cur.FullName, cur.HTMLURL = r.FullName, r.HTMLURL
|
||||||
|
if r.Empty {
|
||||||
|
if cur.RID == "" {
|
||||||
|
cur.State, cur.Error = stWaiting, ""
|
||||||
|
m.save(cur)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.publish(r, &cur); err != nil {
|
||||||
|
cur.State, cur.Error = stError, err.Error()
|
||||||
|
log.Printf("sync %s: %v", r.FullName, err)
|
||||||
|
} else {
|
||||||
|
cur.State, cur.Error, cur.LastSync = stSynced, "", time.Now().UTC()
|
||||||
|
}
|
||||||
|
m.save(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mirror) save(r repoState) {
|
||||||
|
if err := m.state.put(r); err != nil {
|
||||||
|
log.Printf("state: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish fetches the repository from Gitea into its bare mirror, creates its Radicle repository on
|
||||||
|
// first sight, and pushes what changed.
|
||||||
|
func (m *mirror) publish(r *repo, cur *repoState) error {
|
||||||
|
dir := filepath.Join(m.repoDir, fmt.Sprintf("%d.git", r.ID))
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if _, err := m.git("", "init", "--quiet", "--bare", dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src := m.gitea.base + "/" + r.FullName + ".git"
|
||||||
|
args := append([]string{"-c", "http.extraHeader=" + m.gitea.gitAuth(), "fetch", "--quiet", "--prune", "--no-tags", src}, refspecs...)
|
||||||
|
if _, err := m.git(dir, args...); err != nil {
|
||||||
|
return fmt.Errorf("fetch from Gitea: %w", err)
|
||||||
|
}
|
||||||
|
// rad init and the project's default branch read HEAD.
|
||||||
|
if _, err := m.git(dir, "symbolic-ref", "HEAD", "refs/heads/"+r.DefaultBranch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digest, err := m.refsDigest(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cur.RID == "" {
|
||||||
|
// A previous attempt may have created it and failed afterwards: rad init leaves the remote.
|
||||||
|
if rid := m.remoteRID(dir); rid != "" {
|
||||||
|
cur.RID = rid
|
||||||
|
} else {
|
||||||
|
rid, err := m.radInit(dir, r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rad init: %w", err)
|
||||||
|
}
|
||||||
|
cur.RID = rid
|
||||||
|
log.Printf("sync %s: created %s", r.FullName, rid)
|
||||||
|
}
|
||||||
|
cur.DefaultBranch = r.DefaultBranch
|
||||||
|
m.save(*cur) // before anything else can fail: never init the same repository twice
|
||||||
|
} else if m.remoteRID(dir) == "" {
|
||||||
|
// The mirror was deleted (it is disposable): point a fresh one at the existing repository.
|
||||||
|
if err := m.addRemote(dir, cur.RID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.Crefs {
|
||||||
|
if err := m.radCanonicalTags(cur.RID); err != nil {
|
||||||
|
log.Printf("sync %s: canonical tags rule: %v (tags stay under the node's namespace)", r.FullName, err)
|
||||||
|
} else {
|
||||||
|
cur.Crefs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if digest == cur.Refs && cur.State == stSynced && cur.DefaultBranch == r.DefaultBranch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := m.git(dir, append([]string{"push", "--prune", "--porcelain", "-o", "no-sync", "rad"}, refspecs...)...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("push to Radicle: %w", err)
|
||||||
|
}
|
||||||
|
if changed := pushedRefs(out); len(changed) > 0 {
|
||||||
|
log.Printf("sync %s → %s: %s", r.FullName, cur.RID, strings.Join(changed, ", "))
|
||||||
|
}
|
||||||
|
if cur.DefaultBranch != r.DefaultBranch {
|
||||||
|
if err := m.radDefaultBranch(cur.RID, r.DefaultBranch); err != nil {
|
||||||
|
return fmt.Errorf("default branch: %w", err)
|
||||||
|
}
|
||||||
|
cur.DefaultBranch = r.DefaultBranch
|
||||||
|
}
|
||||||
|
cur.Refs = digest
|
||||||
|
m.announce(cur.RID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refsDigest summarises the mirror's branches and tags; equal digests mean nothing to push.
|
||||||
|
func (m *mirror) refsDigest(dir string) (string, error) {
|
||||||
|
out, err := m.git(dir, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(out))
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushedRefs reads `git push --porcelain` output and returns the refs that changed, for the log.
|
||||||
|
// Porcelain lines are "<flag>\t<from>:<to>\t<summary>"; '=' is up to date.
|
||||||
|
func pushedRefs(out string) []string {
|
||||||
|
var changed []string
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Split(line, "\t")
|
||||||
|
if len(f) < 3 || len(f[0]) != 1 || f[0] == "=" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
to := f[1]
|
||||||
|
if i := strings.LastIndexByte(to, ':'); i >= 0 {
|
||||||
|
to = to[i+1:]
|
||||||
|
}
|
||||||
|
changed = append(changed, strings.TrimPrefix(strings.TrimPrefix(to, "refs/heads/"), "refs/")+" "+f[2])
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// queue holds the repositories waiting for a sync, each at most once, served by one worker: a
|
||||||
|
// burst of webhooks for one repository costs one sync, and two syncs never race on a mirror or on
|
||||||
|
// the node's storage.
|
||||||
|
type queue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
order []int64
|
||||||
|
pending map[int64]bool
|
||||||
|
wake chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQueue() *queue {
|
||||||
|
return &queue{pending: map[int64]bool{}, wake: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *queue) add(id int64) {
|
||||||
|
q.mu.Lock()
|
||||||
|
if !q.pending[id] {
|
||||||
|
q.pending[id] = true
|
||||||
|
q.order = append(q.order, id)
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case q.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next pops the oldest repository, or reports false when the queue is empty.
|
||||||
|
func (q *queue) next() (int64, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if len(q.order) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id := q.order[0]
|
||||||
|
q.order = q.order[1:]
|
||||||
|
delete(q.pending, id)
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *queue) run(work func(id int64)) {
|
||||||
|
for {
|
||||||
|
id, ok := q.next()
|
||||||
|
if !ok {
|
||||||
|
<-q.wake
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
work(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Everything Radicle is done with the stock `rad` CLI and `git-remote-rad`, against the node's own
|
||||||
|
// home: the repository is created and signed by the node's key (its only delegate), written into
|
||||||
|
// the node's storage, and announced through the node's control socket. The key has no passphrase
|
||||||
|
// (the install step creates it that way), so nothing prompts.
|
||||||
|
|
||||||
|
var ridRe = regexp.MustCompile(`rad:z[1-9A-HJ-NP-Za-km-z]+`)
|
||||||
|
|
||||||
|
func (m *mirror) env() []string {
|
||||||
|
return append(os.Environ(),
|
||||||
|
"RAD_HOME="+m.radHome,
|
||||||
|
"GIT_TERMINAL_PROMPT=0",
|
||||||
|
// rad's output is read by people in the container log, not by a terminal.
|
||||||
|
"NO_COLOR=1",
|
||||||
|
// Identity documents are git commits; git wants a name even when rad signs them.
|
||||||
|
"GIT_AUTHOR_NAME=ipcr-forge", "GIT_AUTHOR_EMAIL=ipcr-forge@localhost",
|
||||||
|
"GIT_COMMITTER_NAME=ipcr-forge", "GIT_COMMITTER_EMAIL=ipcr-forge@localhost",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes a command in dir (when set) and returns its stdout; on failure the error carries
|
||||||
|
// the end of stderr, which is where git and rad explain themselves.
|
||||||
|
func (m *mirror) run(timeout time.Duration, dir, name string, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = m.env()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if len(msg) > 500 {
|
||||||
|
msg = "…" + msg[len(msg)-500:]
|
||||||
|
}
|
||||||
|
// Never echo an argument list: a fetch carries the Gitea token in one.
|
||||||
|
return stdout.String(), fmt.Errorf("%s %s: %v: %s", name, args[0], err, msg)
|
||||||
|
}
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mirror) git(dir string, args ...string) (string, error) {
|
||||||
|
return m.run(10*time.Minute, dir, "git", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mirror) rad(dir string, args ...string) (string, error) {
|
||||||
|
return m.run(2*time.Minute, dir, "rad", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// radInit creates the Radicle repository for r from its bare mirror and returns its RID. It also
|
||||||
|
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
|
||||||
|
// the `rad` remote that pushes go to.
|
||||||
|
func (m *mirror) radInit(dir string, r *repo) (string, error) {
|
||||||
|
desc := r.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "Mirrored from " + r.HTMLURL
|
||||||
|
}
|
||||||
|
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
|
||||||
|
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if rid := m.remoteRID(dir); rid != "" {
|
||||||
|
return rid, nil
|
||||||
|
}
|
||||||
|
if rid := ridRe.FindString(out); rid != "" {
|
||||||
|
return rid, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no repository ID in rad init's output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteRID reads the RID from the mirror's `rad` remote (rad://z…), or "" when there is none.
|
||||||
|
func (m *mirror) remoteRID(dir string) string {
|
||||||
|
out, err := m.git(dir, "config", "--get", "remote.rad.url")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ridFromURL(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ridFromURL(u string) string {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if !strings.HasPrefix(u, "rad://") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
z, _, _ := strings.Cut(strings.TrimPrefix(u, "rad://"), "/")
|
||||||
|
return "rad:" + z
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRemote recreates what rad init sets up: fetch from the repository, push into this node's
|
||||||
|
// namespace of it.
|
||||||
|
func (m *mirror) addRemote(dir, rid string) error {
|
||||||
|
nid, err := m.rad("", "self", "--nid")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z := strings.TrimPrefix(rid, "rad:")
|
||||||
|
if _, err := m.git(dir, "remote", "add", "rad", "rad://"+z); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = m.git(dir, "config", "remote.rad.pushurl", "rad://"+z+"/"+strings.TrimSpace(nid))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// radCanonicalTags adds the rule that makes a delegate's tags the repository's refs/tags/*. Without
|
||||||
|
// it a tag exists only under the node's namespace, where `rad clone` and the explorer do not show
|
||||||
|
// it. One delegate (this node), so a threshold of 1.
|
||||||
|
func (m *mirror) radCanonicalTags(rid string) error {
|
||||||
|
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Canonical tags",
|
||||||
|
"--description", "Tags pushed by the forge are the repository's tags.",
|
||||||
|
"--payload", "xyz.radicle.crefs", "rules", `{"refs/tags/*":{"threshold":1,"allow":"delegates"}}`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mirror) radDefaultBranch(rid, branch string) error {
|
||||||
|
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Default branch",
|
||||||
|
"--description", "Follows the Gitea repository.",
|
||||||
|
"--payload", "xyz.radicle.project", "defaultBranch", fmt.Sprintf("%q", branch))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// announce tells the network about the new refs. Best effort: a node without peers is not an error,
|
||||||
|
// and the node announces again on its own schedule.
|
||||||
|
func (m *mirror) announce(rid string) {
|
||||||
|
if _, err := m.rad("", "sync", rid, "--announce", "--timeout", "30s"); err != nil {
|
||||||
|
log.Printf("announce %s: %v", rid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:example
|
||||||
|
var exampleFiles embed.FS
|
||||||
|
|
||||||
|
const tokenName = "ipcr-forge-bridge"
|
||||||
|
|
||||||
|
// setup is the install step, run after the stack is up and on every start (each action is
|
||||||
|
// idempotent). It signs in once as the admin with the server's default app password, because
|
||||||
|
// Gitea's token API accepts nothing else, and leaves:
|
||||||
|
//
|
||||||
|
// SECRETS_DIR/gitea-token public-only + read:repository: the daemon lists and clones public
|
||||||
|
// repositories, and cannot see any other. Minted only when missing.
|
||||||
|
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
|
||||||
|
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
|
||||||
|
// created or repaired (matched by URL), never duplicated.
|
||||||
|
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
|
||||||
|
//
|
||||||
|
// The admin password is used here only and never stored. If it was changed since install, the
|
||||||
|
// step leaves the existing token and hook alone instead of failing the start.
|
||||||
|
func setup() error {
|
||||||
|
secrets := env("SECRETS_DIR", "/secrets")
|
||||||
|
g := newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), filepath.Join(secrets, "gitea-token"))
|
||||||
|
g.password = os.Getenv("GITEA_PASSWORD")
|
||||||
|
if g.password == "" {
|
||||||
|
return errors.New("GITEA_PASSWORD is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea may still be migrating, and its admin account may not exist yet. Bounded: an install
|
||||||
|
// step that blocks forever holds the install open forever.
|
||||||
|
var code int
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
if code, err = g.call("GET", "/user", nil, nil); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if code == http.StatusUnauthorized && g.hasToken() {
|
||||||
|
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Gitea not ready after 240s: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !g.hasToken() {
|
||||||
|
g.call("DELETE", "/users/"+g.owner+"/tokens/"+tokenName, nil, nil)
|
||||||
|
var t struct {
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
}
|
||||||
|
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
|
||||||
|
"name": tokenName, "scopes": []string{"public-only", "read:repository"},
|
||||||
|
}, &t); err != nil {
|
||||||
|
return fmt.Errorf("token: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeSecret(g.tokenFile, t.SHA1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("setup: token %s written", tokenName)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretFile := filepath.Join(secrets, "hook-secret")
|
||||||
|
secret, _ := os.ReadFile(secretFile)
|
||||||
|
if strings.TrimSpace(string(secret)) == "" {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
secret = []byte(hex.EncodeToString(b))
|
||||||
|
if err := writeSecret(secretFile, string(secret)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ensureHook(g, env("HOOK_URL", "http://ipcr-forge:8081/hooks/gitea"), strings.TrimSpace(string(secret))); err != nil {
|
||||||
|
return fmt.Errorf("webhook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
|
||||||
|
// Not fatal: the forge works without its example.
|
||||||
|
if err := ensureExample(g, name); err != nil {
|
||||||
|
log.Printf("setup: example repository: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSecret(path, value string) error {
|
||||||
|
tmp := path + ".new"
|
||||||
|
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureHook creates the system webhook, or rewrites the one already pointing at url (Gitea never
|
||||||
|
// returns a hook's secret, so it is set every time rather than compared).
|
||||||
|
func ensureHook(g *gitea, url, secret string) error {
|
||||||
|
config := map[string]string{"url": url, "content_type": "json", "secret": secret, "is_system_webhook": "true"}
|
||||||
|
events := []string{"push", "create", "delete", "repository"}
|
||||||
|
var hooks []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
}
|
||||||
|
if _, err := g.call("GET", "/admin/hooks?type=all&limit=50", nil, &hooks); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, h := range hooks {
|
||||||
|
if h.Config["url"] == url {
|
||||||
|
_, err := g.call("PATCH", fmt.Sprintf("/admin/hooks/%d", h.ID), map[string]any{
|
||||||
|
"config": config, "events": events, "active": true, "branch_filter": "*",
|
||||||
|
}, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := g.call("POST", "/admin/hooks", map[string]any{
|
||||||
|
"type": "gitea", "config": config, "events": events, "active": true, "branch_filter": "*",
|
||||||
|
}, nil)
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("setup: system webhook → %s", url)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
|
||||||
|
// repository that already has content is left as it is: it may be the user's by now.
|
||||||
|
func ensureExample(g *gitea, name string) error {
|
||||||
|
var r repo
|
||||||
|
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
|
||||||
|
switch {
|
||||||
|
case code == http.StatusNotFound:
|
||||||
|
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
|
||||||
|
"name": name, "private": false, "default_branch": "main",
|
||||||
|
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
|
||||||
|
}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("setup: created %s/%s", g.owner, name)
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case !r.Empty:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []map[string]string
|
||||||
|
err = fs.WalkDir(exampleFiles, "example", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := exampleFiles.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files = append(files, map[string]string{
|
||||||
|
"operation": "create", "path": strings.TrimPrefix(p, "example/"),
|
||||||
|
"content": base64.StdEncoding.EncodeToString(b),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
|
||||||
|
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
|
||||||
|
}, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository states, as shown on the page.
|
||||||
|
const (
|
||||||
|
stSynced = "synced" // Radicle has what Gitea has
|
||||||
|
stError = "error" // the last sync failed; retried on the next event or reconcile
|
||||||
|
stWaiting = "waiting" // empty repository: nothing to publish yet
|
||||||
|
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
|
||||||
|
)
|
||||||
|
|
||||||
|
// repoState is what the bridge remembers per Gitea repository, keyed by Gitea's numeric ID so a
|
||||||
|
// rename or a transfer keeps its Radicle ID.
|
||||||
|
type repoState struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url,omitempty"`
|
||||||
|
RID string `json:"rid,omitempty"`
|
||||||
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
LastSync time.Time `json:"last_sync,omitzero"`
|
||||||
|
// A digest of the refs last pushed: a reconcile that finds the same refs pushes nothing.
|
||||||
|
Refs string `json:"refs,omitempty"`
|
||||||
|
// The canonical-tags rule is in the repository's identity (see rad.go).
|
||||||
|
Crefs bool `json:"crefs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
repos map[int64]*repoState
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadState(path string) (*state, error) {
|
||||||
|
s := &state{path: path, repos: map[int64]*repoState{}}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m map[string]*repoState
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range m {
|
||||||
|
s.repos[r.ID] = r
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns a copy of the repository's state (a zero one, with the ID set, when unknown).
|
||||||
|
func (s *state) get(id int64) repoState {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if r, ok := s.repos[id]; ok {
|
||||||
|
return *r
|
||||||
|
}
|
||||||
|
return repoState{ID: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *state) known(id int64) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, ok := s.repos[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// put stores r and writes the file (renamed into place, so a reader never sees half of it).
|
||||||
|
func (s *state) put(r repoState) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.repos[r.ID] = &r
|
||||||
|
m := make(map[string]*repoState, len(s.repos))
|
||||||
|
for id, v := range s.repos {
|
||||||
|
m[strconv.FormatInt(id, 10)] = v
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := filepath.Join(filepath.Dir(s.path), "."+filepath.Base(s.path)+".new")
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *state) ids() []int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
ids := make([]int64, 0, len(s.repos))
|
||||||
|
for id := range s.repos {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// list is the page's view: every repository, by name, without internal bookkeeping.
|
||||||
|
func (s *state) list() []repoState {
|
||||||
|
s.mu.Lock()
|
||||||
|
out := make([]repoState, 0, len(s.repos))
|
||||||
|
for _, r := range s.repos {
|
||||||
|
v := *r
|
||||||
|
v.Refs = ""
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web
|
||||||
|
var webFiles embed.FS
|
||||||
|
|
||||||
|
// webHandler serves the forge's public page. Everything on it is public anyway: links, the
|
||||||
|
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
|
||||||
|
// repositories (public on Radicle). Nothing here changes anything.
|
||||||
|
func webHandler(st *state, publishedFile string) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
page, _ := webFiles.ReadFile("web/index.html")
|
||||||
|
icon, _ := webFiles.ReadFile("web/icon.png")
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Write(page)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /icon.png", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "max-age=3600")
|
||||||
|
w.Write(icon)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /images.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, err := os.ReadFile(publishedFile)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "nothing published yet", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Write(b)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /repos.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
json.NewEncoder(w).Encode(st.list())
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok\n"))
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -6,21 +6,21 @@
|
|||||||
<title>IPCR Forge</title>
|
<title>IPCR Forge</title>
|
||||||
<link rel="icon" href="icon.png">
|
<link rel="icon" href="icon.png">
|
||||||
<!--
|
<!--
|
||||||
Plain file, deliberately not a Maison .tmpl: it contains no dollar-brace sequence, and every
|
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
|
||||||
address is derived in the browser from this page's own hostname (ipcr-forge-<domain> →
|
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<domain>,
|
||||||
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
||||||
Live data: /images.json (IPCR's published.json), /ci/ (the CI broker's report pages) and the
|
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
|
||||||
Radicle API's node info.
|
mirror state) and the Radicle API's node info.
|
||||||
-->
|
-->
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
||||||
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d;
|
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
||||||
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80;
|
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -65,6 +65,8 @@
|
|||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
||||||
.scroll { overflow-x: auto; }
|
.scroll { overflow-x: auto; }
|
||||||
|
.state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
.state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); }
|
||||||
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -78,67 +80,62 @@
|
|||||||
<div class="muted" id="host"></div>
|
<div class="muted" id="host"></div>
|
||||||
</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. Work in Gitea as
|
||||||
service. Code lives on Radicle, this node builds it with GitHub-style workflows, and IPCR serves
|
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
|
||||||
the image from IPFS.</p>
|
mirrored to the Radicle peer-to-peer network.</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-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a>
|
||||||
<a class="card" href="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 forge 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>
|
<a class="card" href="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="how">How it works</h2>
|
<h2 id="how">How it works</h2>
|
||||||
<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</code> a <code>v*</code> tag to Gitea, or create it in Gitea's web UI</small></div>
|
||||||
<div class="arrow">→</div>
|
<div class="arrow">→</div>
|
||||||
<div class="step"><b>2 · Trigger</b><small>this node receives the tag and the Radicle CI broker picks it up</small></div>
|
<div class="step"><b>2 · Build</b><small>Gitea Actions runs <code>.gitea/workflows/</code> and pushes the image to <code>localhost:5000</code></small></div>
|
||||||
<div class="arrow">→</div>
|
<div class="arrow">→</div>
|
||||||
<div class="step"><b>3 · Build</b><small><code>act</code> runs the repo's <code>.github/workflows/</code> and pushes the image</small></div>
|
<div class="step"><b>3 · IPFS</b><small>IPCR imports each tag and publishes it under this forge's IPNS name</small></div>
|
||||||
<div class="arrow">→</div>
|
<div class="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 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
||||||
<div class="arrow">→</div>
|
|
||||||
<div class="step"><b>5 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p>Steps 2 to 4 are automatic and usually take a few minutes. The very first build on this server
|
<p>Steps 2 and 3 are automatic and usually take a few minutes. The very first build on this server
|
||||||
takes longer (about 5 to 10 minutes) while it downloads its build images. A repository needs no
|
takes longer (about 5 to 10 minutes) while it downloads its build images.</p>
|
||||||
configuration beyond committing a workflow. There is no second copy of it anywhere: the build
|
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
|
||||||
reads the commit straight from this node. Builds of the default branch and of patches also
|
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
|
||||||
write their result back into the repository, signed by the node, as a Radicle job.</p>
|
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
|
||||||
<p class="note">Only changes signed by one of the repository's delegates are built: release tags,
|
and it stays available even when this server is not. Private repositories are never copied.</p>
|
||||||
the default branch and their patches. A build can publish an image, so it gets the trust of a
|
|
||||||
release.</p>
|
<h2 id="example">Try it with the example</h2>
|
||||||
|
<p>The install created <a id="link-hello" href="#"><code>gitea_admin/ipcr-hello</code></a>, a tiny web
|
||||||
|
page with a Dockerfile and the workflow that publishes it.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open it in Gitea, then <b>Tags → New tag</b> (or <b>Releases → New release</b>): name
|
||||||
|
<code>v1.0.0</code>, target <code>main</code>.</li>
|
||||||
|
<li>Follow the build under the repository's <b>Actions</b> tab.</li>
|
||||||
|
<li>About a minute after it finishes, the image appears below under
|
||||||
|
<a href="#images">Published images</a>, and the tag on <a href="#repos">Radicle</a>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
<h2 id="images">Published images</h2>
|
<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>
|
||||||
|
|
||||||
<h2 id="publish">Publish a project</h2>
|
<h2 id="publish">Publish your own project</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li><b>Install Radicle</b> on your machine and create an identity: see
|
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
|
||||||
<a href="https://radicle.xyz/" target="_blank" rel="noopener">radicle.xyz</a>, then <code>rad auth</code>.
|
and push your code to it:
|
||||||
Start your own node, which carries your pushes: <code>rad node start</code>.</li>
|
<pre><code id="clone-cmd">git remote add origin https://gitea-<domain>/<owner>/<repo>.git
|
||||||
<li><b>Connect to this server's node</b> so your pushes reach it:
|
git push -u origin main</code></pre>
|
||||||
<pre><code id="connect-cmd">rad node connect <node-id>@<this server>:8776</code></pre></li>
|
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
|
||||||
<li><b>Publish the repository.</b> It has to be a Git repository with at least one commit:
|
Applications</b>).</li>
|
||||||
<pre><code>git init -b main
|
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
|
||||||
git add -A
|
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
|
||||||
git commit -m "First commit"
|
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
|
||||||
rad init</code></pre>
|
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
|
||||||
<code>rad init</code> prints the repository ID (<code>rad:z…</code>).</li>
|
so it can reach that registry.
|
||||||
<li><b>Let this node seed it.</b> The node only keeps repositories it is told to (seeding policy
|
<pre><code>name: image
|
||||||
<i>block</i>). In this server's Terminal app or over SSH:
|
on:
|
||||||
<pre><code>docker exec radicle-node rad seed rad:z… --scope all</code></pre>
|
|
||||||
It ends with <code>✗ Target not met: could not fetch from […]</code>. That is about the public
|
|
||||||
Radicle seeds, not this server, and can be ignored. The repository is in place once it shows
|
|
||||||
in the <a class="radicle-link" href="#">Radicle explorer</a>.</li>
|
|
||||||
<li><b>Add a workflow</b> at <code>.github/workflows/build.yml</code>, as on GitHub. This one
|
|
||||||
builds the <code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag.
|
|
||||||
<code>vars.IMAGE</code> is set for every repository (this node's staging registry plus the
|
|
||||||
repository's name); there is no secret to configure. Two lines differ from a GitHub-hosted
|
|
||||||
workflow: <code>context: .</code> (builds the checkout, not a GitHub URL) and buildx's
|
|
||||||
<code>network=host</code> (so its builder can reach the staging registry). Commit it
|
|
||||||
(<code>git add .github && git commit -m "Build the image"</code>).
|
|
||||||
<pre><code>on:
|
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
jobs:
|
jobs:
|
||||||
@@ -149,7 +146,7 @@ jobs:
|
|||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v5
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
images: ${{ vars.IMAGE }}
|
images: localhost:5000/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
@@ -163,33 +160,45 @@ jobs:
|
|||||||
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 -a v1.0.0 -m v1.0.0
|
<pre><code>git add .gitea && git commit -m "Build the image"
|
||||||
git push rad main v1.0.0</code></pre>
|
git tag v1.0.0
|
||||||
Follow the build under <a href="ci/">CI runs</a>. A minute after it finishes, the tag appears
|
git push origin main v1.0.0</code></pre>
|
||||||
under <a href="#images">Published images</a>.</li>
|
The image is published as <code><owner>/<repo>:1.0.0</code> under this forge's name.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
|
||||||
|
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
|
||||||
|
a private repository. Gitea holds a pull request from a fork until someone with write access approves
|
||||||
|
its workflow run: read the workflow before you approve.</p>
|
||||||
|
|
||||||
<h2 id="use">Use an image</h2>
|
<h2 id="use">Use an image</h2>
|
||||||
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
||||||
server and on any server running IPCR:</p>
|
server and on any server running IPCR:</p>
|
||||||
<pre><code>services:
|
<pre><code>services:
|
||||||
myapp:
|
myapp:
|
||||||
image: ipcr.localhost:4767/ipns/k51qzi5uqu5d…:1.0.0 # a tag: follows new builds of 1.0.0
|
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0 # a tag: follows new builds of 1.0.0
|
||||||
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
|
# 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>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0</code>.
|
||||||
<code>/ipfs/<cid></code> is the image's content address and can never change.</p>
|
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/<cid></code> is the
|
||||||
|
image's content address and can never change.</p>
|
||||||
<p class="note">For a readable name, point a name you own at this forge's publisher name
|
<p class="note">For a readable name, point a name you own at this forge's publisher name
|
||||||
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
|
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
|
<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>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>
|
<code>ipcr.localhost:4767/ipns/example.eth/<owner>/<repo>:1.0.0</code>.</li>
|
||||||
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
|
<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>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
|
||||||
<code>ipcr.localhost:4767/ipns/example.com/<repo>:1.0.0</code>.</li>
|
<code>ipcr.localhost:4767/ipns/example.com/<owner>/<repo>:1.0.0</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
|
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
|
||||||
|
|
||||||
|
<h2 id="repos">On Radicle</h2>
|
||||||
|
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node
|
||||||
|
(<code id="node-id">…</code>). Clone one with <code>rad clone <id></code> from any Radicle node.</p>
|
||||||
|
<div id="repos-body"><p class="muted">Loading…</p></div>
|
||||||
|
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
|
||||||
|
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p>
|
||||||
|
|
||||||
<h2 id="install">Pull from another server</h2>
|
<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,
|
||||||
from this server and from every other server that already pulled it.</p>
|
from this server and from every other server that already pulled it.</p>
|
||||||
@@ -210,14 +219,15 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
<h2 id="addresses">Addresses on this server</h2>
|
<h2 id="addresses">Addresses on this server</h2>
|
||||||
<div class="scroll"><table>
|
<div class="scroll"><table>
|
||||||
<tr><th>Address</th><th>What</th></tr>
|
<tr><th>Address</th><th>What</th></tr>
|
||||||
|
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr>
|
||||||
|
<tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></tr>
|
||||||
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
|
<tr><td><a id="t-radicle" 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 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 · Radicle CI broker · act · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -232,9 +242,18 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
a.href = url(prefix);
|
a.href = url(prefix);
|
||||||
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
||||||
}
|
}
|
||||||
|
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
|
||||||
document.getElementById("host").textContent = base || host;
|
document.getElementById("host").textContent = base || host;
|
||||||
setLink("link-radicle", "radicle");
|
setLink("link-gitea", "gitea");
|
||||||
Array.prototype.forEach.call(document.querySelectorAll("a.radicle-link"), function (a) { a.href = url("radicle"); });
|
all("a.gitea-link", function (a) { a.href = url("gitea"); });
|
||||||
|
all("a.radicle-link", function (a) { a.href = url("radicle"); });
|
||||||
|
document.getElementById("link-hello").href = base ? url("gitea") + "/gitea_admin/ipcr-hello" : "#";
|
||||||
|
if (base) {
|
||||||
|
document.getElementById("clone-cmd").textContent =
|
||||||
|
"git remote add origin " + url("gitea") + "/<owner>/<repo>.git\ngit push -u origin main";
|
||||||
|
}
|
||||||
|
setLink("t-gitea", "gitea", true);
|
||||||
|
setLink("t-forge", "ipcr-forge", true);
|
||||||
setLink("t-radicle", "radicle", true);
|
setLink("t-radicle", "radicle", true);
|
||||||
setLink("t-radicle-api", "radicle-api", true);
|
setLink("t-radicle-api", "radicle-api", true);
|
||||||
|
|
||||||
@@ -244,6 +263,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
if (cls) e.className = cls;
|
if (cls) e.className = cls;
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
function link(href, text) { var a = el("a", text); a.href = href; return a; }
|
||||||
function copyButton(text) {
|
function copyButton(text) {
|
||||||
var b = el("button", "Copy", "copy");
|
var b = el("button", "Copy", "copy");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
@@ -255,6 +275,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
});
|
});
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
|
||||||
function cmpTags(a, b) {
|
function cmpTags(a, b) {
|
||||||
if (a === "latest") return -1;
|
if (a === "latest") return -1;
|
||||||
if (b === "latest") return 1;
|
if (b === "latest") return 1;
|
||||||
@@ -267,33 +288,37 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
function table(headers) {
|
||||||
|
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
|
||||||
|
headers.forEach(function (h) { head.appendChild(el("th", h)); });
|
||||||
|
t.appendChild(head);
|
||||||
|
wrap.appendChild(t);
|
||||||
|
return { wrap: wrap, t: t };
|
||||||
|
}
|
||||||
|
|
||||||
var body = document.getElementById("images-body");
|
// Published images: IPCR's published.json.
|
||||||
|
var imagesBody = document.getElementById("images-body");
|
||||||
fetch("images.json", { cache: "no-store" })
|
fetch("images.json", { cache: "no-store" })
|
||||||
.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) {
|
if (pub.publisher) {
|
||||||
document.getElementById("publisher-name").textContent = pub.publisher;
|
document.getElementById("publisher-name").textContent = pub.publisher;
|
||||||
Array.prototype.forEach.call(document.querySelectorAll("span.publisher"), function (e) { e.textContent = pub.publisher; });
|
all("span.publisher", function (e) { e.textContent = pub.publisher; });
|
||||||
}
|
}
|
||||||
var repos = Object.keys(pub.images || {}).sort();
|
var repos = Object.keys(pub.images || {}).sort();
|
||||||
if (!repos.length) throw new Error("empty");
|
if (!repos.length) throw new Error("empty");
|
||||||
body.textContent = "";
|
imagesBody.textContent = "";
|
||||||
repos.forEach(function (repo) {
|
repos.forEach(function (repo) {
|
||||||
var r = pub.images[repo];
|
var r = pub.images[repo];
|
||||||
body.appendChild(el("h3", repo));
|
imagesBody.appendChild(el("h3", repo));
|
||||||
var wrap = el("div", null, "scroll");
|
var tb = table(["Tag", "Compose line", ""]);
|
||||||
var t = el("table");
|
|
||||||
var head = el("tr");
|
|
||||||
["Tag", "Compose line", ""].forEach(function (h) { head.appendChild(el("th", h)); });
|
|
||||||
t.appendChild(head);
|
|
||||||
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
||||||
var info = r.tags[tag];
|
var info = r.tags[tag];
|
||||||
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
|
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
|
||||||
var tr = el("tr");
|
var tr = el("tr");
|
||||||
var td1 = el("td"); td1.appendChild(el("code", tag));
|
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||||
if (info.at) td1.appendChild(el("div", info.at.replace("T", " ").replace("Z", " UTC"), "muted"));
|
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
|
||||||
var td2 = el("td"); td2.appendChild(el("code", line));
|
var td2 = el("td"); td2.appendChild(el("code", line));
|
||||||
var pin = el("div", null, "muted");
|
var pin = el("div", null, "muted");
|
||||||
pin.appendChild(document.createTextNode("this build only: "));
|
pin.appendChild(document.createTextNode("this build only: "));
|
||||||
@@ -301,31 +326,54 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
|
|||||||
td2.appendChild(pin);
|
td2.appendChild(pin);
|
||||||
var td3 = el("td"); td3.appendChild(copyButton(line));
|
var td3 = el("td"); td3.appendChild(copyButton(line));
|
||||||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||||
t.appendChild(tr);
|
tb.t.appendChild(tr);
|
||||||
});
|
});
|
||||||
wrap.appendChild(t);
|
imagesBody.appendChild(tb.wrap);
|
||||||
body.appendChild(wrap);
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
body.innerHTML = "";
|
imagesBody.innerHTML = "";
|
||||||
body.appendChild(el("p", "Nothing published yet. Push a v* tag from a repository with a workflow (see below); its image appears here a few minutes later.", "muted"));
|
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// The node's ID and dialable address, for `rad node connect`. The Radicle API is public and
|
// Radicle mirrors: the bridge's state.
|
||||||
// CORS-enabled (the explorer calls it from the browser). The address must be the node's own
|
var reposBody = document.getElementById("repos-body");
|
||||||
// externalAddresses entry (radicle-<ip>.sslip.io:8776): the *-<domain> names are behind an
|
fetch("repos.json", { cache: "no-store" })
|
||||||
// HTTP proxy that does not carry port 8776.
|
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||||
|
.then(function (repos) {
|
||||||
|
if (!repos || !repos.length) throw new Error("empty");
|
||||||
|
reposBody.textContent = "";
|
||||||
|
var tb = table(["Repository", "Radicle", "State"]);
|
||||||
|
repos.forEach(function (r) {
|
||||||
|
var tr = el("tr");
|
||||||
|
var td1 = el("td");
|
||||||
|
td1.appendChild(r.html_url ? link(r.html_url, r.full_name) : el("span", r.full_name));
|
||||||
|
var td2 = el("td");
|
||||||
|
if (r.rid) {
|
||||||
|
td2.appendChild(base ? link(url("radicle") + "/nodes/radicle-api-" + base + "/" + r.rid, r.rid) : el("code", r.rid));
|
||||||
|
var clone = el("div", null, "muted");
|
||||||
|
clone.appendChild(el("code", "rad clone " + r.rid));
|
||||||
|
td2.appendChild(clone);
|
||||||
|
} else { td2.appendChild(el("span", "—", "muted")); }
|
||||||
|
var td3 = el("td");
|
||||||
|
td3.appendChild(el("span", r.state, "state " + r.state));
|
||||||
|
if (r.last_sync) td3.appendChild(el("div", when(r.last_sync), "muted"));
|
||||||
|
if (r.error) td3.appendChild(el("div", r.error, "muted"));
|
||||||
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||||
|
tb.t.appendChild(tr);
|
||||||
|
});
|
||||||
|
reposBody.appendChild(tb.wrap);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
reposBody.innerHTML = "";
|
||||||
|
reposBody.appendChild(el("p", "No public repository mirrored yet. Every public repository in Gitea appears here within a minute of its first push.", "muted"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The node's ID. The Radicle API is public and CORS-enabled (the explorer calls it from the browser).
|
||||||
if (base) {
|
if (base) {
|
||||||
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (node) {
|
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
|
||||||
var addrs = (node.config && node.config.externalAddresses) || [];
|
|
||||||
if (node.id && addrs.length) {
|
|
||||||
document.getElementById("connect-cmd").textContent =
|
|
||||||
"rad node connect " + node.id + "@" + addrs[0];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function () {});
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
Reference in New Issue
Block a user