Rename the bridge to the forge service; this repository is the add-on

- bridge/ → forge/, module and binary ipcr-forge, image ghcr.io/yundera/ipcr-forge
  (was ipcr-forge-bridge); releases are plain vX.Y.Z tags (workflow image.yml)
- data folders $AppID/bridge/{state,secrets} → $AppID/forge/..., in-container /forge
- docs link to the engine (Yundera/ipcr) instead of relative paths; README
This commit is contained in:
Worph
2026-10-05 13:21:11 +00:00
parent 291564bf1d
commit 98ff2f5783
14 changed files with 169 additions and 128 deletions
+13 -12
View File
@@ -1,13 +1,14 @@
name: bridge name: image
# IPCR Forge's bridge image (bridge/): Gitea → Radicle mirror + the forge's page. # IPCR Forge's service image (forge/): Gitea → Radicle mirror, the forge's page and admin, the
# staging gate. ghcr.io/yundera/ipcr-forge; a `vX.Y.Z` tag publishes X.Y.Z.
on: on:
push: push:
branches: [main] branches: [main]
tags: ["bridge-v*"] tags: ["v*"]
paths: ["bridge/**", ".github/workflows/bridge.yml"] paths: ["forge/**", ".github/workflows/image.yml"]
pull_request: pull_request:
paths: ["bridge/**", ".github/workflows/bridge.yml"] paths: ["forge/**", ".github/workflows/image.yml"]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -15,7 +16,7 @@ permissions:
packages: write packages: write
env: env:
IMAGE: ghcr.io/yundera/ipcr-forge-bridge IMAGE: ghcr.io/yundera/ipcr-forge
jobs: jobs:
vet: vet:
@@ -24,9 +25,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: bridge/go.mod go-version-file: forge/go.mod
- run: test -z "$(gofmt -l .)" && go vet ./... && go test ./... - run: test -z "$(gofmt -l .)" && go vet ./... && go test ./...
working-directory: bridge working-directory: forge
image: image:
needs: vet needs: vet
@@ -45,17 +46,17 @@ jobs:
with: with:
images: ${{ env.IMAGE }} images: ${{ env.IMAGE }}
tags: | tags: |
type=match,pattern=bridge-v(.*),group=1 type=semver,pattern={{version}}
type=ref,event=branch type=ref,event=branch
type=sha type=sha
# amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is # amd64 only: the Radicle CLI is fetched as its x86_64 release, and the Forge listing is
# amd64 only. # amd64 only.
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: bridge context: forge
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }} build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=bridge cache-from: type=gha,scope=forge
cache-to: type=gha,mode=max,scope=bridge cache-to: type=gha,mode=max,scope=forge
+33 -33
View File
@@ -10,15 +10,15 @@ name: ipcr-forge
# → ipcr-kubo / ipcr-registry # → ipcr-kubo / ipcr-registry
# docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>:1.2.3 (anyone) # docker pull ipcr.localhost:4767/ipns/<forge name>/<owner>/<repo>:1.2.3 (anyone)
# #
# gitea ──system webhook──▶ ipcr-forge (bridge) ──rad init / git push rad──▶ radicle-node # gitea ──system webhook──▶ ipcr-forge ──rad init / git push rad──▶ radicle-node
# every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node. # every PUBLIC repository, one way: Radicle is a distribution mirror signed by this node.
# #
# The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the bridge: how to # The tile opens Gitea. ipcr-forge-<domain> is the forge's own page, served by the forge service: how to
# publish, the published images with the `image:` line to use, and the Radicle mirrors. # publish, the published images with the `image:` line to use, and the Radicle mirrors.
# #
# Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea, # Gitea, Radicle and IPCR services are copied from their standalone listings (Apps/Gitea,
# Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are # Apps/Radicle, Apps/IPCR), so each keeps its own comments and rationale.md; the differences are
# marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,bridge,ci,ipcr}/. # marked "Forge:". Data folders move, one subfolder per part: $AppID/{gitea,radicle,forge,ci,ipcr}/.
# #
# Replaces the Gitea, 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 (gitea-, 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).
@@ -30,19 +30,19 @@ name: ipcr-forge
services: services:
# ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════ # ═══ Bridge — the forge's page, and Gitea → Radicle ════════════════════════════
# ipcr-forge-bridge (bridge/ in the IPCR repository). Receives Gitea's system webhook on :8081 # ipcr-forge (forge/ in the ipcr-forge repository). Receives Gitea's system webhook on :8081
# (internal: no Caddy route) and mirrors every public repository to the Radicle node below, # (internal: no Caddy route) and mirrors every public repository to the Radicle node below,
# with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080. # with the stock `rad` CLI against the node's own home. Serves the forge's page on :8080.
# Its install step (forge-setup, below) mints its Gitea token and registers the webhook. # Its install step (forge-setup, below) mints its Gitea token and registers the webhook.
ipcr-forge: ipcr-forge:
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 image: ghcr.io/yundera/ipcr-forge:0.4.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
command: ["ipcr-forge-bridge", "serve"] command: ["ipcr-forge", "serve"]
cpu_shares: 30 cpu_shares: 30
deploy: deploy:
resources: resources:
@@ -53,7 +53,7 @@ services:
GITEA_URL: http://gitea:3000 GITEA_URL: http://gitea:3000
GITEA_OWNER: gitea_admin GITEA_OWNER: gitea_admin
SECRETS_DIR: /secrets SECRETS_DIR: /secrets
STATE_DIR: /bridge STATE_DIR: /forge
RAD_HOME: /radicle-home RAD_HOME: /radicle-home
IPCR_PUBLISHED: /srv/ipcr-state/published.json IPCR_PUBLISHED: /srv/ipcr-state/published.json
RECONCILE: 10m RECONCILE: 10m
@@ -77,9 +77,9 @@ services:
# radicle-api has. # radicle-api has.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/ - ${DATA_ROOT:-/DATA}/AppData/$AppID/radicle/home/:/radicle-home/
# repos.json (Gitea repository → Radicle ID) and one bare mirror per repository. # repos.json (Gitea repository → Radicle ID) and one bare mirror per repository.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state:/bridge - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state:/forge
# The Gitea token and the webhook secret the install step writes. Read-only here. # The Gitea token and the webhook secret the install step writes. Read-only here.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/secrets:/secrets:ro - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/secrets:/secrets:ro
# published.json → /images.json, and the admin API's token. Read-only. # published.json → /images.json, and the admin API's token. Read-only.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro - ${DATA_ROOT:-/DATA}/AppData/$AppID/ipcr/state:/srv/ipcr-state:ro
# Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys # Kubo's keystore, read-only: the publisher key, for encrypted backups (Kubo exports keys
@@ -89,7 +89,7 @@ services:
- gitea - gitea
- radicle-node - radicle-node
healthcheck: healthcheck:
test: ["CMD", "ipcr-forge-bridge", "health"] test: ["CMD", "ipcr-forge", "health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -160,7 +160,7 @@ services:
# Forge: off. Images go to the staging registry that IPCR follows; a second registry would # 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. # be a second place an image could be published from.
GITEA__packages__ENABLED: "false" GITEA__packages__ENABLED: "false"
# Forge: webhooks may reach the bridge (ipcr-forge, on this server's network) and, as by # Forge: webhooks may reach the forge service (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 # default, any public address. Not `private`: that would let any user's webhook reach every
# container on the shared network. # container on the shared network.
GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge
@@ -277,11 +277,11 @@ services:
# The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs # The staging registry's front door: `localhost:5000` to the daemon and to every job (act runs
# jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain # jobs on the host network, which is this namespace), forge-docker:5000 to ipcr-gateway. Plain
# HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in # HTTP, which Docker allows to localhost only. It checks a login on every request: a job logs in
# as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the bridge sets on it) and may # as its repository (<owner>/<repo>, the IPCR_PUSH_TOKEN secret the forge service sets on it) and may
# push that name only; IPCR logs in as the importer (state/staging-auth, which it generates). # push that name only; IPCR logs in as the importer (state/staging-auth, which it generates).
# Same image as the bridge (`ipcr-forge-bridge gate`, bridge/gate.go). See rationale.md. # Same image as the forge service (`ipcr-forge gate`, forge/gate.go). See rationale.md.
forge-gate: forge-gate:
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 image: ghcr.io/yundera/ipcr-forge:0.4.0
container_name: forge-gate container_name: forge-gate
user: $PUID:$PGID user: $PUID:$PGID
read_only: true read_only: true
@@ -289,7 +289,7 @@ services:
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
restart: unless-stopped restart: unless-stopped
network_mode: service:forge-docker network_mode: service:forge-docker
command: ["ipcr-forge-bridge", "gate"] command: ["ipcr-forge", "gate"]
cpu_shares: 20 cpu_shares: 20
deploy: deploy:
resources: resources:
@@ -301,8 +301,8 @@ services:
GATE_UPSTREAM: unix:///run/staging/registry.sock GATE_UPSTREAM: unix:///run/staging/registry.sock
GATE_CREDENTIALS: /gate/credentials.json GATE_CREDENTIALS: /gate/credentials.json
volumes: volumes:
# Hashes only, written by the bridge. # Hashes only, written by the forge service.
- ${DATA_ROOT:-/DATA}/AppData/$AppID/bridge/state/gate:/gate:ro - ${DATA_ROOT:-/DATA}/AppData/$AppID/forge/state/gate:/gate:ro
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
depends_on: depends_on:
- forge-docker - forge-docker
@@ -342,7 +342,7 @@ services:
- ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging - ${DATA_ROOT:-/DATA}/AppData/$AppID/ci/registry-socket:/run/staging
# ═══ Radicle — the public mirror ═══════════════════════════════════════════════ # ═══ Radicle — the public mirror ═══════════════════════════════════════════════
# Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The bridge # Verbatim from Apps/Radicle; only its data folder moves to $AppID/radicle/. The forge service
# creates and pushes repositories in this node's storage; the node replicates them. # 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.
@@ -499,7 +499,7 @@ services:
# 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://forge-docker:5000} IMPORT_WATCH: ${IPCR_IMPORT_WATCH:-http://forge-docker:5000}
IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-} IMPORT_AUTH: ${IPCR_IMPORT_AUTH:-}
# Forge: IPCR's login at the staging registry's gate, generated on first start; the bridge # Forge: IPCR's login at the staging registry's gate, generated on first start; the forge service
# reads it (read-only ipcr/state) and gives it the importer's rights at the gate. # reads it (read-only ipcr/state) and gives it the importer's rights at the gate.
IMPORT_AUTH_FILE: /data/state/staging-auth IMPORT_AUTH_FILE: /data/state/staging-auth
IMPORT_AUTH_GENERATE: "true" IMPORT_AUTH_GENERATE: "true"
@@ -508,8 +508,8 @@ services:
# /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an # /ipns/<forge key>/<repo>:<tag>. One name for the whole forge, which a DNSLink domain or an
# ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>. # ENS name can point at: ipcr.localhost:4767/ipns/example.eth/<repo>:<tag>.
IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge} IMPORT_PUBLISHER: ${IPCR_IMPORT_PUBLISHER:-forge}
# Forge: publish only the repositories the bridge allows (public Gitea repositories, minus # Forge: publish only the repositories the forge service allows (public Gitea repositories, minus
# those switched off in the admin pages). Until the bridge sends its list, nothing is. # those switched off in the admin pages). Until the forge service sends its list, nothing is.
IMPORT_ALLOW_REQUIRED: "true" IMPORT_ALLOW_REQUIRED: "true"
# Forge: delete each tag from the staging registry once published (its blobs go at the # Forge: delete each tag from the staging registry once published (its blobs go at the
# registry's next start, see forge-registry). # registry's next start, see forge-registry).
@@ -518,7 +518,7 @@ services:
# its name; resolvers cache them 5 minutes, so a new tag shows up quickly. # its name; resolvers cache them 5 minutes, so a new tag shows up quickly.
IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h} IPNS_LIFETIME: ${IPCR_IPNS_LIFETIME:-168h}
IPNS_TTL: 5m IPNS_TTL: 5m
# Forge: the admin API, for the bridge, on ipcr-admin. Bearer token in state/admin-token. # Forge: the admin API, for the forge service, on ipcr-admin. Bearer token in state/admin-token.
ADMIN_LISTEN: ":4769" ADMIN_LISTEN: ":4769"
IMPORT_INTERVAL: 60s IMPORT_INTERVAL: 60s
STATE_DIR: /data/state STATE_DIR: /data/state
@@ -547,7 +547,7 @@ services:
- ipcr-internal - ipcr-internal
# Forge only: reaches the staging registry for the import poller above. # Forge only: reaches the staging registry for the import poller above.
- ci-internal - ci-internal
# Forge only: the admin API, reached by the bridge. # Forge only: the admin API, reached by the forge service.
- ipcr-admin - ipcr-admin
cpu_shares: 50 cpu_shares: 50
deploy: deploy:
@@ -841,15 +841,15 @@ x-compose-app:
schema_version: 2 schema_version: 2
folders: folders:
# ── Bridge ── # ── Bridge ──
- path: /DATA/AppData/$AppID/bridge/state - path: /DATA/AppData/$AppID/forge/state
user: $PUID user: $PUID
group: $PGID group: $PGID
- path: /DATA/AppData/$AppID/bridge/secrets - path: /DATA/AppData/$AppID/forge/secrets
user: $PUID user: $PUID
group: $PGID group: $PGID
mode: "0700" mode: "0700"
# The gate's credentials (hashes), written by the bridge, read by the gate. # The gate's credentials (hashes), written by the forge service, read by the gate.
- path: /DATA/AppData/$AppID/bridge/state/gate - path: /DATA/AppData/$AppID/forge/state/gate
user: $PUID user: $PUID
group: $PGID group: $PGID
# ── Gitea ── # ── Gitea ──
@@ -897,7 +897,7 @@ x-compose-app:
user: $PUID user: $PUID
group: $PGID group: $PGID
mode: "0700" mode: "0700"
# Created ahead of Kubo's first start: the bridge mounts it, and Docker would otherwise create # Created ahead of Kubo's first start: the forge service mounts it, and Docker would otherwise create
# it as root, where Kubo cannot write its keys. # it as root, where Kubo cannot write its keys.
- path: /DATA/AppData/$AppID/ipcr/kubo/keystore - path: /DATA/AppData/$AppID/ipcr/kubo/keystore
user: $PUID user: $PUID
@@ -927,7 +927,7 @@ x-compose-app:
mode: "0755" mode: "0755"
init: init:
# In order: Radicle's identity before its node starts, then Gitea's admin, then the runner # 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. # token and the forge service'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
@@ -969,21 +969,21 @@ x-compose-app:
mv /runner/token.new /runner/token mv /runner/token.new /runner/token
chmod 600 /runner/token chmod 600 /runner/token
when: absent:/DATA/AppData/$AppID/gitea/runner/.runner when: absent:/DATA/AppData/$AppID/gitea/runner/.runner
# The bridge's Gitea tokens (public-only read; and write:repository for push secrets), the # The forge service's Gitea tokens (public-only read; and write:repository for push secrets), the
# webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and # webhook secret, the system webhook, the admin pages' OAuth2 app, the root organisation and
# the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook. # the example repository (in that organisation). Idempotent, so on every start: it repairs the webhook.
# The admin password is used here only, never stored. # The admin password is used here only, never stored.
- name: forge-setup - name: forge-setup
image: ghcr.io/yundera/ipcr-forge-bridge:0.4.0 image: ghcr.io/yundera/ipcr-forge:0.4.0
user: $PUID:$PGID user: $PUID:$PGID
phase: post_up phase: post_up
network: $APP_NET network: $APP_NET
volumes: volumes:
- /DATA/AppData/$AppID/bridge/secrets:/secrets - /DATA/AppData/$AppID/forge/secrets:/secrets
command: command:
- sh - sh
- -c - -c
- GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 IPCR_ROOT_ORG="$IPCR_ROOT_ORG" PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge-bridge setup - GITEA_PASSWORD="$APP_DEFAULT_PASSWORD" GITEA_URL=http://gitea:3000 IPCR_ROOT_ORG="$IPCR_ROOT_ORG" PUBLIC_HOSTS="ipcr-forge-$APP_DOMAIN,ipcr-forge-$APP_PUBLIC_IP_DASH.nip.io,ipcr-forge-$APP_PUBLIC_IP_DASH.sslip.io" ipcr-forge setup
when: always when: always
webui-host: gitea-${domain} webui-host: gitea-${domain}
webui-path: / webui-path: /
+22 -22
View File
@@ -1,7 +1,7 @@
# IPCR Forge — Rationale # IPCR Forge — Rationale
IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon, IPCR Forge bundles three store apps (Gitea, Radicle, IPCR), a CI runner with its own Docker daemon,
and the bridge (`ipcr-forge-bridge`), which mirrors public Gitea repositories to Radicle and and the forge service (`ipcr-forge`), which mirrors public Gitea repositories to Radicle and
serves the forge's page. The Gitea, Radicle and IPCR services are copied from their listings, 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, so each keeps the deviations its standalone listing already argues. This document lists them,
then covers only what the bundle adds. then covers only what the bundle adds.
@@ -23,11 +23,11 @@ with. See docs/forge.md.
**Added by the bundle:** **Added by the bundle:**
1. **`forge-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. **The bridge (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's 2. **The forge service (`ipcr-forge`) mounts the Radicle node's home read-write**, including the node's
key. It publishes repositories signed by that key. key. It publishes repositories signed by that key.
3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the 3. **A system webhook and its allow-list.** Gitea may deliver webhooks to `ipcr-forge` on the
server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`). server's network (`GITEA__webhook__ALLOWED_HOST_LIST: external,ipcr-forge`).
4. **An install step that uses the admin password** to mint the bridge's token and to register 4. **An install step that uses the admin password** to mint the forge service's token and to register
that webhook. that webhook.
5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry 5. **A cross-app network link.** `ipcr-gateway` joins `ci-internal`, where the staging registry
is. is.
@@ -37,15 +37,15 @@ with. See docs/forge.md.
domains and host ports. domains and host ports.
9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The 9. **Admin pages with their own login** (`/admin`): Gitea OAuth2, Gitea site admins only. The
install step registers the OAuth2 app. install step registers the OAuth2 app.
10. **The bridge mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted. 10. **The forge service mounts Kubo's keystore read-only**, so it can export the publisher key, encrypted.
11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer 11. **An admin API in `ipcr-gateway`** (`:4769`) on a new internal network, `ipcr-admin`. Its Bearer
token is in `ipcr/state/admin-token`, mode 0644. token is in `ipcr/state/admin-token`, mode 0644.
12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry 12. **A staging gate** (`forge-gate`), running in the CI daemon's network namespace. The registry
behind it has no network and listens on a unix socket. behind it has no network and listens on a unix socket.
13. **A second Gitea token for the bridge,** with `write:repository` and `read:user`. It sets each 13. **A second Gitea token for the forge service,** with `write:repository` and `read:user`. It sets each
publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions. publishing repository's `IPCR_PUSH_TOKEN` Actions secret and checks name collisions.
14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the 14. **A credential IPCR generates for itself** (`ipcr/state/staging-auth`, mode 0644), read by the
bridge. forge service.
15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories 15. **The root organisation** (`IPCR_ROOT_ORG`): setup creates it if missing, and its repositories
are also published at the root of the forge's name. are also published at the root of the forge's name.
16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this 16. **A public, unauthenticated, read-only registry route** (`ipcr-<domain>` → `ipcr-public`), so this
@@ -65,11 +65,11 @@ container that reaches the daemon over a unix socket.
**2. The node's home.** Publishing to Radicle means three things: creating a repository in the **2. The node's home.** Publishing to Radicle means three things: creating a repository in the
node's storage, signing its refs with the node's key, and announcing them through the node's node's storage, signing its refs with the node's key, and announcing them through the node's
control socket. Those live in that folder. This is the mount `radicle-api` already has. The bridge control socket. Those live in that folder. This is the mount `radicle-api` already has. The forge service
uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it. uses the stock `rad` CLI (1.10.1, the node's version) rather than reimplementing any of it.
**3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute **3. The webhook.** Pushes reach Radicle within seconds instead of at the next 10-minute
reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the bridge sits reconcile. Gitea's default allow-list (`external`) refuses private addresses, and the forge service sits
on one. The list adds exactly one host name. `private` would let any user's webhook reach every on one. The list adds exactly one host name. `private` would let any user's webhook reach every
container on the shared network. container on the shared network.
@@ -105,7 +105,7 @@ on the PCS gate, which is not on every host.
**10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available **10. The keystore mount.** Kubo exports keys from its CLI only (`key/export` is not available
over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700 over its HTTP API). ipcrd runs as root without capabilities, so it cannot read a `$PUID` 0700
folder; giving it `DAC_READ_SEARCH` was rejected once already. The bridge runs as `$PUID` and can. folder; giving it `DAC_READ_SEARCH` was rejected once already. The forge service runs as `$PUID` and can.
**11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist **11. The admin API and its token.** Unpublishing, moving tags, key restore and the allowlist
change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is change ipcrd's own state (the published tree, `published.json`), so they live in ipcrd. The token is
@@ -127,7 +127,7 @@ repository it belongs to. Setting a secret takes `write:repository`. The token i
`public-only`, so collision checks also see private users and organisations. `public-only`, so collision checks also see private users and organisations.
**14. IPCR's own credential.** Same handoff as the admin token: ipcrd (root without capabilities) **14. IPCR's own credential.** Same handoff as the admin token: ipcrd (root without capabilities)
cannot write into another uid's folder, so it writes into its own state folder, which the bridge cannot write into another uid's folder, so it writes into its own state folder, which the forge service
already mounts read-only. already mounts read-only.
**15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose **15. The root organisation.** Short names (`metadec.eth/ipcr-hello`) need one owner whose
@@ -141,17 +141,17 @@ purpose: the images are public on IPFS anyway.
## Security mitigations in place ## Security mitigations in place
**The bridge** **The forge service**
- **It sees public repositories only.** Its Gitea token is scoped `public-only` and - **It sees public repositories only.** Its Gitea token is scoped `public-only` and
`read:repository`. Gitea hides every private or limited repository from it, and it can write `read:repository`. Gitea hides every private or limited repository from it, and it can write
nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped. nothing in Gitea. The code checks visibility a second time. Forks and pull mirrors are skipped.
- **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256 - **Webhooks are authenticated and carry no content.** Each delivery must pass the HMAC-SHA256
check against a random secret written at install (`bridge/secrets`, 0700). A delivery only says check against a random secret written at install (`forge/secrets`, 0700). A delivery only says
*which* repository changed. The bridge re-reads that repository from Gitea, so a forged payload *which* repository changed. The forge service re-reads that repository from Gitea, so a forged payload
can at most cause a pointless sync. Port 8081 has no Caddy route. can at most cause a pointless sync. Port 8081 has no Caddy route.
- **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem, - **It runs unprivileged:** `$PUID`, every capability dropped, a read-only root filesystem,
`no-new-privileges`, 256 MB. One worker does one sync at a time. `no-new-privileges`, 256 MB. One worker does one sync at a time.
- **The node's key stays in the bridge and the node.** No CI job mounts the Radicle home. - **The node's key stays in the forge service and the node.** No CI job mounts the Radicle home.
**The CI daemon** **The CI daemon**
- **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an - **No network listener.** `dockerd` gets an explicit `--host=unix:///run/dind/docker.sock` and an
@@ -202,8 +202,8 @@ purpose: the images are public on IPFS anyway.
- **Can be switched off** in the admin pages (`config.json` `"public": false`). - **Can be switched off** in the admin pages (`config.json` `"public": false`).
**The page** **The page**
- **The public page cannot change anything.** The bridge serves it read-only: the embedded page, - **The public page cannot change anything.** The forge service serves it read-only: the embedded page,
IPCR's `published.json` (mounted read-only) and the bridge's repository list. IPCR's `published.json` (mounted read-only) and the forge service's repository list.
**The admin pages** **The admin pages**
- **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the - **Only Gitea site admins get in.** The check is `is_admin`, read from Gitea's own API through the
@@ -215,12 +215,12 @@ purpose: the images are public on IPFS anyway.
custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a custom `X-IPCR-Admin` header is required. A cross-site form cannot send that header, and a
cross-site script cannot without a CORS preflight, which is never answered. cross-site script cannot without a CORS preflight, which is never answered.
- **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by - **The admin API is only on `ipcr-admin`.** That network is `internal: true` and shared only by
the bridge and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's the forge service and `ipcr-gateway`; every call needs the Bearer token, compared in constant time. Kubo's
own API stays on `ipcr-internal`. own API stays on `ipcr-internal`.
- **The cleartext key never crosses the network.** The bridge encrypts it in memory (AES-256-GCM, - **The cleartext key never crosses the network.** The forge service encrypts it in memory (AES-256-GCM,
PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A PBKDF2 at 600k iterations, at least 12 characters of passphrase) and ipcrd decrypts it. A
restore never deletes a key: the previous one is kept, renamed. restore never deletes a key: the previous one is kept, renamed.
- **The allowlist is closed by default.** Until the bridge sends its list, nothing is published - **The allowlist is closed by default.** Until the forge service sends its list, nothing is published
(`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it. (`IMPORT_ALLOW_REQUIRED`). Private repositories are never on it.
## Alternatives considered and rejected ## Alternatives considered and rejected
@@ -234,7 +234,7 @@ purpose: the images are public on IPFS anyway.
| A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones | | A per-repository webhook | Every repository would need setup; one system webhook covers all of them, including new ones |
| Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from | | Gitea's package registry as the staging registry (v1) | Needs tokens for the workflow and for IPCR, plus the `DAC_READ_SEARCH` capability for IPCR to read its token. It would also be a second place images get published from |
| Host Docker socket for jobs | Any workflow would get root on the host | | 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 | | A long-lived admin token for the forge service | The webhook is repaired by the install step on every start instead, so the running service never holds admin rights |
## Known limitations ## Known limitations
@@ -252,7 +252,7 @@ purpose: the images are public on IPFS anyway.
repository changes hands. repository changes hands.
- **Existing workflows must add a login step,** or their pushes fail with 401. - **Existing workflows must add a login step,** or their pushes fail with 401.
- **Private repositories cannot publish:** they get no push credential. - **Private repositories cannot publish:** they get no push credential.
- **The bridge can read the publisher key.** A compromise of the public-facing bridge leaks it. That - **The forge service can read the publisher key.** A compromise of the public-facing forge service leaks it. That
is the trust it already has with the Radicle node key. is the trust it already has with the Radicle node key.
- **The admin token is world-readable** inside the app's state folder on the host. - **The admin token is world-readable** inside the app's state folder on the host.
- **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by - **Old keys accumulate.** `<key>-replaced-*` keys are kept and republished by Kubo until removed by
@@ -268,7 +268,7 @@ Everything lives under `/DATA/AppData/ipcr-forge/`, one folder per part:
| --- | --- | | --- | --- |
| `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config | | `gitea/` | repositories, the database (SQLite), configuration, the runner's registration and config |
| `radicle/` | node identity (key pair), seeded repositories, Caddy config | | `radicle/` | node identity (key pair), seeded repositories, Caddy config |
| `bridge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials | | `forge/` | `state/`: the Gitea → Radicle mapping, one bare mirror per repository (disposable), the admin session key and the repository toggles (`admin.json`); `gate/credentials.json` (token hashes); `secrets/` (0700): its two Gitea tokens, the webhook secret, the OAuth2 client credentials |
| `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) | | `ci/` | the daemon's socket, its image store (`docker/`), the staging registry (`registry/`, disposable) and its socket (`registry-socket/`) |
| `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token | | `ipcr/` | Kubo repo (node identity, IPNS keys, pinned images), TLS CA, watcher state, `config.json` (name, allowlist), the admin token |
+34 -1
View File
@@ -1 +1,34 @@
# ipcr-forge # IPCR Forge
A self-hosted forge whose releases live on IPFS: work in **Gitea** as usual, its Actions build your
images, the [IPCR](https://github.com/Yundera/ipcr) engine publishes them under one IPNS name (and
your ENS or DNS name), and every public repository is mirrored to **Radicle**.
```
git push origin v1.0.0
docker pull ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0 # any host running IPCR
docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0 # anywhere, through the forge
```
This repository is the add-on; [Yundera/ipcr](https://github.com/Yundera/ipcr) is the engine (the
registry gateway, IPFS import, IPNS naming and admin API), used here as the image
`ghcr.io/yundera/ipcr`.
- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified.
- **[Apps/IPCR-Forge/](Apps/IPCR-Forge/)**: the Yundera AppStore listing (compose, rationale, seed).
- **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only):
the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's
gate and per-repository push credentials, and the install step.
- **[Apps/Gitea/](Apps/Gitea/)**, **[Apps/Radicle/](Apps/Radicle/)**: the standalone listings the
forge is built from.
## Develop
```sh
cd forge && go vet ./... && go test ./...
docker build -t ghcr.io/yundera/ipcr-forge:dev forge
```
Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z`
([.github/workflows/image.yml](.github/workflows/image.yml)). The listing pins both images: this
one and the engine's.
+39 -32
View File
@@ -1,5 +1,11 @@
# IPCR Forge — design notes # IPCR Forge — design notes
This repository is the forge, an add-on to the IPCR engine
([Yundera/ipcr](https://github.com/Yundera/ipcr): the registry gateway, the IPFS import, the IPNS
naming, the admin API). The engine knows nothing about Gitea, Radicle or this forge. The forge uses
the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its
[design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons).
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. The listing's own security argument is in is shaped this way, and what was learned building it. The listing's own security argument is in
[rationale.md](../Apps/IPCR-Forge/rationale.md). [rationale.md](../Apps/IPCR-Forge/rationale.md).
@@ -24,8 +30,8 @@ A forge people actually want to use, whose releases do not depend on it staying
| **v3** (this) | **Gitea** | **Gitea Actions** | — | | **v3** (this) | **Gitea** | **Gitea Actions** | — |
v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the 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 CI's own Docker daemon (no tokens, no registry credentials). The forge service is v1's glue reversed and
rewritten in Go ([bridge/](../bridge/)). rewritten in Go ([forge/](../forge/)).
The objection that removed Gitea in v2 was "two places where state can originate". It does not apply 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 here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues
@@ -37,7 +43,7 @@ opened on the Radicle side are not read.
developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI) developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI)
│ │
▼ ▼
gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (bridge) gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (forge service)
│ · git fetch from Gitea │ · git fetch from Gitea
│ Actions · rad init (first time) │ Actions · rad init (first time)
▼ · git push rad (signed by the node) ▼ · git push rad (signed by the node)
@@ -54,9 +60,9 @@ anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner
The two legs do not depend on each other: a repository can be built without being mirrored The two legs do not depend on each other: a repository can be built without being mirrored
(private repositories), and mirrored without being built (no workflow). (private repositories), and mirrored without being built (no workflow).
## The bridge ## The forge service
[bridge/](../bridge/): one Go binary, `ipcr-forge-bridge`, image `ghcr.io/yundera/ipcr-forge-bridge`. [forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`.
- **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as - **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as
`gitea_admin` with the default app password and does four things: `gitea_admin` with the default app password and does four things:
@@ -95,9 +101,9 @@ code checks visibility again anyway.
- **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a - **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 distribution channel here, not the trust root. The `/rad/<rid>/…` image-naming idea in
[naming.md](naming.md) is weaker for it. [naming.md](https://github.com/Yundera/ipcr/blob/main/docs/naming.md) is weaker for it.
- **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist - **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist
the bridge keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus the forge service keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus
those switched off in the admin pages. Images built from private repositories are therefore not those switched off in the admin pages. Images built from private repositories are therefore not
published. published.
- **Each repository publishes its own name only.** The staging registry sits behind a gate (see - **Each repository publishes its own name only.** The staging registry sits behind a gate (see
@@ -117,17 +123,17 @@ code checks visibility again anyway.
## Admin ## Admin
`https://ipcr-forge-<domain>/admin`. It is served by the bridge and only lets in Gitea site `https://ipcr-forge-<domain>/admin`. It is served by the forge service and only lets in Gitea site
administrators. administrators.
**Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per **Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per
host. The flow is the authorization code with PKCE and the `read:user` scope. The bridge reads host. The flow is the authorization code with PKCE and the `read:user` scope. The forge service reads
`is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h; `is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h;
the key lives in `bridge/state`). Writes require the `X-IPCR-Admin` header and a same-origin the key lives in `forge/state`). Writes require the `X-IPCR-Admin` header and a same-origin
request. request.
**Behind the page.** The page calls the bridge, and the bridge calls ipcrd's admin API **Behind the page.** The page calls the forge service, and the forge service calls ipcrd's admin API
(`gateway/admin.go`). That API listens on `:4769`, on the `ipcr-admin` network (internal, bridge ([`gateway/admin.go`](https://github.com/Yundera/ipcr/blob/main/gateway/admin.go) in the IPCR engine). That API listens on `:4769`, on the `ipcr-admin` network (internal, forge service
and gateway only), and needs a Bearer token from `ipcr/state/admin-token`. and gateway only), and needs a Bearer token from `ipcr/state/admin-token`.
| Section | What it does | | Section | What it does |
@@ -145,12 +151,12 @@ ipfs key import forge key
``` ```
Then publish with a sequence number above the network's. Then publish with a sequence number above the network's.
**Backup format** (`gateway/keybackup.go`, copied in `bridge/`): **Backup format** ([`gateway/keybackup.go`](https://github.com/Yundera/ipcr/blob/main/gateway/keybackup.go) in the engine, copied in `forge/`):
- JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM; - JSON containing PBKDF2-SHA256 (600k iterations) and AES-256-GCM;
- the additional authenticated data binds the key's name and its `k51…`; - the additional authenticated data binds the key's name and its `k51…`;
- the plaintext is exactly what `ipfs key export` writes. - the plaintext is exactly what `ipfs key export` writes.
The bridge seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the The forge service seals (it can read the keystore) and ipcrd opens, so the cleartext key never crosses the
network. network.
## Root organisation (short names) ## Root organisation (short names)
@@ -163,7 +169,7 @@ ipcr.localhost:4767/ipns/metadec.eth/ipcr-hello:1.0.0
ipcr.localhost:4767/ipns/metadec.eth/metadec/ipcr-hello:1.0.0 ipcr.localhost:4767/ipns/metadec.eth/metadec/ipcr-hello:1.0.0
ipcr.localhost:4767/ipns/k51…/ipcr-hello:1.0.0 ipcr.localhost:4767/ipns/k51…/ipcr-hello:1.0.0
``` ```
- **How it is passed on:** the bridge sends IPCR `allow.aliases` (`{"metadec/ipcr-hello": - **How it is passed on:** the forge service sends IPCR `allow.aliases` (`{"metadec/ipcr-hello":
["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree ["ipcr-hello"]}`). IPCR publishes each tag under the full path and every alias in one tree
update. update.
- **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the - **Alias sync:** on each pass IPCR adds and removes alias folders to match. Switching the
@@ -212,8 +218,8 @@ IPCR ── Basic ipcr:<generated> ── forge-docker:5000 ──────
▼ ▼
forge-registry (no network) forge-registry (no network)
``` ```
- **`forge-gate`** is `ipcr-forge-bridge gate`, built from the same image. - **`forge-gate`** is `ipcr-forge gate`, built from the same image.
- It checks Basic auth against `bridge/state/gate/credentials.json` (hashes only), re-read when - It checks Basic auth against `forge/state/gate/credentials.json` (hashes only), re-read when
it changes. it changes.
- **A repository** may GET, HEAD, POST, PUT and PATCH under `/v2/<owner>/<repo>/{blobs,manifests,tags}/`. - **A repository** may GET, HEAD, POST, PUT and PATCH under `/v2/<owner>/<repo>/{blobs,manifests,tags}/`.
Cross-repository blob mounts are stripped, so the client uploads instead. Cross-repository blob mounts are stripped, so the client uploads instead.
@@ -223,19 +229,19 @@ IPCR ── Basic ipcr:<generated> ── forge-docker:5000 ──────
the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a the daemon, which sees neither that folder nor a port. So the gate cannot be bypassed short of a
breakout (see Trust). breakout (see Trust).
- **Credentials:** - **Credentials:**
- **Per repository:** the bridge generates a token for each repository that may publish. It - **Per repository:** the forge service generates a token for each repository that may publish. It
sets the repository's `IPCR_PUSH_TOKEN` Actions secret through a `write:repository` token sets the repository's `IPCR_PUSH_TOKEN` Actions secret through a `write:repository` token
(`ipcr-forge-secrets`, minted at setup) and writes the token's hash for the gate. A (`ipcr-forge-secrets`, minted at setup) and writes the token's hash for the gate. A
repository switched off or frozen loses both. "Reissue" in the admin page rotates the token. repository switched off or frozen loses both. "Reissue" in the admin page rotates the token.
- **IPCR:** IPCR generates its own credential at its first start (`IMPORT_AUTH_GENERATE`, - **IPCR:** IPCR generates its own credential at its first start (`IMPORT_AUTH_GENERATE`,
`state/staging-auth`), and the bridge passes its hash to the gate. `state/staging-auth`), and the forge service passes its hash to the gate.
- **Migration:** existing workflows must add the login step (the template below). Without it, a - **Migration:** existing workflows must add the login step (the template below). Without it, a
push fails with `401 Unauthorized` at its first blob check. push fails with `401 Unauthorized` at its first blob check.
## Workflow contract ## Workflow contract
A repository opts in by committing a workflow under `.gitea/workflows/`. The template is A repository opts in by committing a workflow under `.gitea/workflows/`. The template is
[bridge/example/.gitea/workflows/build.yml](../bridge/example/.gitea/workflows/build.yml): [forge/example/.gitea/workflows/build.yml](../forge/example/.gitea/workflows/build.yml):
- `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`, - `docker/login-action` to `localhost:5000`, `username: ${{ github.repository }}`,
`password: ${{ secrets.IPCR_PUSH_TOKEN }}`. `password: ${{ secrets.IPCR_PUSH_TOKEN }}`.
@@ -261,35 +267,35 @@ Carried over from v1 and v2; each one silently produced the wrong result rather
it first. it first.
5. **Radicle has no shared `refs/tags/`** unless the identity carries a canonical-refs rule. Without 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. 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`. The forge service adds the rule right after `rad init`.
New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04): New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the bridge 6. **`rad init` works on a bare repository** (it uses `workdir().unwrap_or(path)`), so the forge service
keeps bare mirrors only. It also seeds the repository on the node and adds the `rad` remote. 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 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. `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 8. **Gitea's default webhook allow-list (`external`) blocks the forge service.** It is on the server's
private network. `ALLOWED_HOST_LIST: external,ipcr-forge` allows exactly that one container. 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. `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 9. **`rad sync --announce` takes its RID positionally** (`rad sync <rid> --announce`); there is no
`--rid` flag. `--rid` flag.
10. **`rad sync` blocks for its timeout when the node has no peers.** The bridge pushes with 10. **`rad sync` blocks for its timeout when the node has no peers.** The forge service pushes with
`-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the `-o no-sync` and announces separately with a 30-second bound, so a push is never held up by the
network. network.
11. **`act_runner` needs about 1 GB.** It holds a job's checkout and actions in memory while it 11. **`act_runner` needs about 1 GB.** It holds a job's checkout and actions in memory while it
copies them into the job container. At 256 MB it was OOM-killed mid-job. The job container copies them into the job container. At 256 MB it was OOM-killed mid-job. The job container
then ran on, orphaned, and Gitea showed the run as "running" until it timed out. then ran on, orphaned, and Gitea showed the run as "running" until it timed out.
12. **The first `rad init` can fail if the node is still starting.** The bridge's retry adopts the 12. **The first `rad init` can fail if the node is still starting.** The forge service's retry adopts the
RID that `rad init` already wrote into the mirror's `rad` remote, so no second repository is RID that `rad init` already wrote into the mirror's `rad` remote, so no second repository is
created (seen on holyhorse). `rad` prints its errors on stdout, which the bridge now logs. created (seen on holyhorse). `rad` prints its errors on stdout, which the forge service now logs.
13. **Gitea's API returns `html_url` built from the internal address** (`http://gitea:3000/…`) when 13. **Gitea's API returns `html_url` built from the internal address** (`http://gitea:3000/…`) when
called through it. The page builds Gitea links from its own host name instead. called through it. The page builds Gitea links from its own host name instead.
14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the 14. **Right after `up`, new domains answer with the gateway's SSO redirect** until Caddy picks up the
labels (about a minute). The runner's registration retries through that window. labels (about a minute). The runner's registration retries through that window.
15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without 15. **`key/export` is CLI-only** in Kubo (`NoRemote`), and ipcrd, which runs as root without
capabilities, cannot read the `$PUID` 0700 keystore. The bridge reads `key_<base32 name>` capabilities, cannot read the `$PUID` 0700 keystore. The forge service reads `key_<base32 name>`
(`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what (`forge` → `key_mzxxez3f`, mode 0400) through a read-only mount. Those bytes are what
`ipfs key export` would write. `ipfs key export` would write.
16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev 16. **Delegated routing does not use the spec's 404.** For a name it doesn't know, delegated-ipfs.dev
@@ -310,7 +316,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
22. **Radicle IDs are derived from the identity document.** `metadec/ipcr-hello` (the example, 22. **Radicle IDs are derived from the identity document.** `metadec/ipcr-hello` (the example,
created again in the organisation) had the same name, description and delegate as created again in the organisation) had the same name, description and delegate as
`gitea_admin/ipcr-hello`, so the same RID. `rad init` failed with "attempt to reinitialize". The `gitea_admin/ipcr-hello`, so the same RID. `rad init` failed with "attempt to reinitialize". The
bridge now puts the Gitea path in the Radicle description, which makes the document unique per forge service now puts the Gitea path in the Radicle description, which makes the document unique per
forge. forge.
23. **Gitea regenerates an OAuth2 app's secret on every update.** `setup` used to update the app at 23. **Gitea regenerates an OAuth2 app's secret on every update.** `setup` used to update the app at
every start (to follow the hosts) and kept the old secret, which broke the admin login every start (to follow the hosts) and kept the old secret, which broke the admin login
@@ -327,7 +333,7 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
## Verified ## Verified
**Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the bridge image, one Docker network): **Locally** (Gitea 1.27.3-rootless, `radicle-seed-node` 1.10.1, the forge service image, one Docker network):
- `setup` run twice leaves exactly one system hook. - `setup` run twice leaves exactly one system hook.
- `ipcr-hello` was created and mirrored with the crefs rule in its identity. - `ipcr-hello` was created and mirrored with the crefs rule in its identity.
@@ -390,11 +396,12 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
- **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though - **Pull from a second IPCR server** (`ipcr.localhost`): verified on holyhorse itself only, though
the public front door was pulled from another machine; v2's images pulled across IPCR servers. the public front door was pulled from another machine; v2's images pulled across IPCR servers.
- **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge-bridge:0.4.0` - **Publish the images**: `ghcr.io/yundera/ipcr:1.5.0` and `ghcr.io/yundera/ipcr-forge:0.4.0`
(workflows `image.yml` and `bridge.yml`, tags `v1.5.0` and `bridge-v0.4.0`). (`ghcr.io/yundera/ipcr` from the engine repository, tag `v1.5.0`; `ghcr.io/yundera/ipcr-forge`
from this one, tag `v0.4.0`).
- **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start - **CI isolation:** jobs on a privileged daemon can reach the host. Rootless DinD does not start
on this platform (AppArmor); a VM-based runner would be the real fix. on this platform (AppArmor); a VM-based runner would be the real fix.
- **Private repositories** cannot publish: the bridge only sees public ones. If wanted, give them - **Private repositories** cannot publish: the forge service only sees public ones. If wanted, give them
push credentials too (the images would still be public on IPFS). push credentials too (the images would still be public on IPFS).
- **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A - **Old keys:** `<key>-replaced-*` keys are kept forever, and Kubo keeps republishing them. A
"delete" in the admin page, once the new key is confirmed. "delete" in the admin page, once the new key is confirmed.
+8 -8
View File
@@ -1,10 +1,10 @@
# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and # ipcr-forge: 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. # the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
# Build context: this folder (bridge/). # Build context: this folder (forge/).
FROM golang:1.24-alpine AS build FROM golang:1.24-alpine AS build
WORKDIR /src WORKDIR /src
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge .
# Radicle's official static (musl) release, checked against its published digest. # Radicle's official static (musl) release, checked against its published digest.
FROM alpine:3.22 AS radicle FROM alpine:3.22 AS radicle
@@ -21,17 +21,17 @@ RUN apk add --no-cache xz \
FROM alpine:3.22 FROM alpine:3.22
ARG VERSION=dev ARG VERSION=dev
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \ LABEL org.opencontainers.image.title="ipcr-forge" \
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \ 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.source="https://github.com/Yundera/ipcr-forge" \
org.opencontainers.image.version="$VERSION" org.opencontainers.image.version="$VERSION"
RUN apk add --no-cache ca-certificates git \ 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 # 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. # another owner ("dubious ownership"); the storage is the node's own, so trust it.
&& git config --system safe.directory '*' && git config --system safe.directory '*'
COPY --from=radicle /out/ /usr/local/bin/ COPY --from=radicle /out/ /usr/local/bin/
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge COPY --from=build /out/ipcr-forge /usr/local/bin/ipcr-forge
ENV RAD_HOME=/radicle-home \ ENV RAD_HOME=/radicle-home \
HOME=/tmp HOME=/tmp
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image. # No entrypoint: the install step runs `sh -c '… ipcr-forge setup'` in this image.
CMD ["ipcr-forge-bridge", "serve"] CMD ["ipcr-forge", "serve"]
+3 -3
View File
@@ -17,12 +17,12 @@ import (
"time" "time"
) )
// The gate is the staging registry's front door (`ipcr-forge-bridge gate`). CI jobs push to // The gate is the staging registry's front door (`ipcr-forge gate`). CI jobs push to
// localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push // localhost:5000 and IPCR imports from it; before the gate, anyone who could run a job could push
// any image name, and IPCR published what it found. Now: // any image name, and IPCR published what it found. Now:
// //
// - a job logs in as its repository: user <owner>/<repo>, password the IPCR_PUSH_TOKEN secret the // - a job logs in as its repository: user <owner>/<repo>, password the IPCR_PUSH_TOKEN secret the
// bridge set on that repository (pushcreds.go). It may read and write that repository's // forge service set on that repository (pushcreds.go). It may read and write that repository's
// manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no // manifests, blobs and tags, and nothing else: no other name, no catalog, no delete, no
// cross-repository blob mount; // cross-repository blob mount;
// - IPCR logs in as the importer (the credential it generated itself): read everything, list the // - IPCR logs in as the importer (the credential it generated itself): read everything, list the
@@ -34,7 +34,7 @@ import (
// Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default // Environment: GATE_LISTEN (default :5000), GATE_UPSTREAM (default
// unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json). // unix:///run/staging/registry.sock), GATE_CREDENTIALS (default /gate/credentials.json).
// gateCreds is credentials.json, written by the bridge: SHA-256 (hex) of each password. // gateCreds is credentials.json, written by the forge service: SHA-256 (hex) of each password.
type gateCreds struct { type gateCreds struct {
Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash Push map[string]string `json:"push"` // "<owner>/<repo>" (lowercase) → hash
Importer map[string]string `json:"importer"` // user → hash Importer map[string]string `json:"importer"` // user → hash
+1 -1
View File
@@ -1,3 +1,3 @@
module ipcr-forge-bridge module ipcr-forge
go 1.24 go 1.24
+1 -1
View File
@@ -12,7 +12,7 @@ package main
// and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and // and exports it (`ipfs key export`, libp2p-protobuf-cleartext). The additional data binds name and
// id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default). // id to the ciphertext, so neither can be edited. Byte slices are base64 (encoding/json's default).
// //
// A copy of gateway/keybackup.go (ipcrd), without its CLI: the bridge writes backups (it can read // A copy of gateway/keybackup.go (ipcrd), without its CLI: the forge service writes backups (it can read
// Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the // Kubo's keystore; ipcrd cannot) and ipcrd reads them, so the cleartext key never crosses the
// network. Keep the two identical; both test the same vector. // network. Keep the two identical; both test the same vector.
+6 -6
View File
@@ -1,4 +1,4 @@
// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code // ipcr-forge — 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. // is replicated, and this service keeps the second following the first. One way only.
// //
// serve (default) webhook receiver + mirror worker + the forge's page // serve (default) webhook receiver + mirror worker + the forge's page
@@ -21,7 +21,7 @@
// GITEA_OWNER the admin account (default gitea_admin) // GITEA_OWNER the admin account (default gitea_admin)
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook // GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
// SECRETS_DIR gitea-token and hook-secret (default /secrets) // SECRETS_DIR gitea-token and hook-secret (default /secrets)
// STATE_DIR repos.json and the bare mirrors (default /bridge) // STATE_DIR repos.json and the bare mirrors (default /forge)
// RAD_HOME the node's Radicle home (default /radicle-home) // RAD_HOME the node's Radicle home (default /radicle-home)
// WEB_LISTEN the page (default :8080) // WEB_LISTEN the page (default :8080)
// HOOK_LISTEN the webhook, internal only (default :8081) // HOOK_LISTEN the webhook, internal only (default :8081)
@@ -42,7 +42,7 @@
// IMPORT_PUBLISHER the publisher key's name (default forge) // IMPORT_PUBLISHER the publisher key's name (default forge)
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in // IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
// the admin page (default: none) // the admin page (default: none)
// GATE_CREDENTIALS the gate's credentials.json (default /bridge/gate/credentials.json) // GATE_CREDENTIALS the gate's credentials.json (default /forge/gate/credentials.json)
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth) // IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
package main package main
@@ -104,7 +104,7 @@ func serve() error {
if err != nil { if err != nil {
return fmt.Errorf("RECONCILE: %w", err) return fmt.Errorf("RECONCILE: %w", err)
} }
stateDir := env("STATE_DIR", "/bridge") stateDir := env("STATE_DIR", "/forge")
st, err := loadState(stateDir + "/repos.json") st, err := loadState(stateDir + "/repos.json")
if err != nil { if err != nil {
return err return err
@@ -166,11 +166,11 @@ func serve() error {
log.Printf("allowlist: %v", err) log.Printf("allowlist: %v", err)
} }
} }
log.Printf("bridge: admin pages on, IPCR admin API %s", ipcr) log.Printf("forge: admin pages on, IPCR admin API %s", ipcr)
} }
go reconcileLoop(m, q, every) go reconcileLoop(m, q, every)
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every) log.Printf("forge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux) return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
} }
+1 -1
View File
@@ -20,7 +20,7 @@ import (
var exampleFiles embed.FS var exampleFiles embed.FS
const ( const (
tokenName = "ipcr-forge-bridge" tokenName = "ipcr-forge"
adminTokenName = "ipcr-forge-secrets" adminTokenName = "ipcr-forge-secrets"
) )
+1 -1
View File
@@ -19,7 +19,7 @@ const (
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted 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 // repoState is what the forge service remembers per Gitea repository, keyed by Gitea's numeric ID so a
// rename or a transfer keeps its Radicle ID. // rename or a transfer keeps its Radicle ID.
type repoState struct { type repoState struct {
ID int64 `json:"id"` ID int64 `json:"id"`
+3 -3
View File
@@ -6,8 +6,8 @@
<title>IPCR Forge admin</title> <title>IPCR Forge admin</title>
<link rel="icon" href="icon.png"> <link rel="icon" href="icon.png">
<!-- <!--
The forge's admin page: Gitea site administrators only (the bridge checks the session for the The forge's admin page: Gitea site administrators only (the forge service checks the session for the
page and for every API call). Everything goes through /admin/api/: the bridge's own endpoints page and for every API call). Everything goes through /admin/api/: the forge service's own endpoints
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/. (repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
--> -->
<style> <style>
@@ -233,7 +233,7 @@
var t = $("toast"); t.textContent = msg; t.style.display = "block"; var t = $("toast"); t.textContent = msg; t.style.display = "block";
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000); clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
} }
// Every call: same origin, with the header the bridge requires on writes. // Every call: same origin, with the header the forge service requires on writes.
function api(method, path, body) { function api(method, path, body) {
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" }; var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); } if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
+4 -4
View File
@@ -6,10 +6,10 @@
<title>IPCR Forge</title> <title>IPCR Forge</title>
<link rel="icon" href="icon.png"> <link rel="icon" href="icon.png">
<!-- <!--
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is Served by the forge service (ipcr-forge, embedded in its binary). Every address is
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<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), /repos.json (the bridge's Gitea → Radicle Live data: /images.json (IPCR's published.json), /repos.json (the forge service's Gitea → Radicle
mirror state) and the Radicle API's node info. mirror state) and the Radicle API's node info.
--> -->
<style> <style>
@@ -363,7 +363,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
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")); 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"));
}); });
// Radicle mirrors: the bridge's state. // Radicle mirrors: the forge service's state.
var reposBody = document.getElementById("repos-body"); var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" }) fetch("repos.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(); })
@@ -374,7 +374,7 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
repos.forEach(function (r) { repos.forEach(function (r) {
var tr = el("tr"); var tr = el("tr");
var td1 = el("td"); var td1 = el("td");
// Not r.html_url: the bridge talks to Gitea's internal address, which is what Gitea puts there. // Not r.html_url: the forge service talks to Gitea's internal address, which is what Gitea puts there.
td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, r.full_name) : el("span", r.full_name)); td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, r.full_name) : el("span", r.full_name));
var td2 = el("td"); var td2 = el("td");
if (r.rid) { if (r.rid) {