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:
Worph
2026-10-04 18:34:26 +00:00
parent f82543f518
commit 6fe4c40cbe
24 changed files with 2275 additions and 594 deletions
+37
View File
@@ -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"]
+199
View File
@@ -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)
}
}
+32
View File
@@ -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 }}
+6
View File
@@ -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"]
+34
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module ipcr-forge-bridge
go 1.24
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+49
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+382
View File
@@ -0,0 +1,382 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPCR Forge</title>
<link rel="icon" href="icon.png">
<!--
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
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.
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
mirror state) and the Radicle API's node info.
-->
<style>
:root {
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
header img { width: 64px; height: 64px; }
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
h2 { font-size: 21px; margin: 40px 0 12px; }
h3 { font-size: 16px; margin: 20px 0 6px; }
p { margin: 8px 0; }
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
a { color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
text-decoration: none; color: inherit; display: block; }
a.card:hover { border-color: var(--accent); }
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
.card span { color: var(--muted); font-size: 14px; }
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 10px 12px; font-size: 14px; }
.step b { display: block; font-size: 15px; }
.step small { color: var(--muted); }
.arrow { align-self: center; color: var(--muted); }
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
overflow-x: auto; line-height: 1.5; }
pre code { background: none; padding: 0; }
ol { padding-left: 22px; }
li { margin: 6px 0; }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; overflow: hidden; font-size: 14px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
tr:last-child td { border-bottom: 0; }
td code { word-break: break-all; }
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
.muted { color: var(--muted); }
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
.scroll { overflow-x: auto; }
.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; }
</style>
</head>
<body>
<main>
<header>
<img src="icon.png" alt="">
<div>
<h1>IPCR Forge</h1>
<div class="muted" id="host"></div>
</div>
</header>
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
mirrored to the Radicle peer-to-peer network.</p>
<div class="cards">
<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="#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="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
</div>
<h2 id="how">How it works</h2>
<div class="pipe">
<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="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="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="step"><b>4 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
</div>
<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.</p>
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
and it stays available even when this server is not. Private repositories are never copied.</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>
<div id="images-body"><p class="muted">Loading…</p></div>
<h2 id="publish">Publish your own project</h2>
<ol>
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
and push your code to it:
<pre><code id="clone-cmd">git remote add origin https://gitea-&lt;domain&gt;/&lt;owner&gt;/&lt;repo&gt;.git
git push -u origin main</code></pre>
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
Applications</b>).</li>
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
workflow with two differences: the image goes to <code>localhost:5000</code>, this forge's
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
so it can reach that registry.
<pre><code>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/&#36;{{ 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: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ steps.meta.outputs.labels }}</code></pre></li>
<li><b>Release:</b>
<pre><code>git add .gitea && git commit -m "Build the image"
git tag v1.0.0
git push origin main v1.0.0</code></pre>
The image is published as <code>&lt;owner&gt;/&lt;repo&gt;:1.0.0</code> under this forge's name.</li>
</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>
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
server and on any server running IPCR:</p>
<pre><code>services:
myapp:
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;:1.0.0 # a tag: follows new builds of 1.0.0
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
<p>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/&lt;cid&gt;</code> is the
image's content address and can never change.</p>
<p class="note">For a readable name, point a name you own at this forge's publisher name
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
<ul>
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
<code>ipns://<span class="publisher">k51…</span></code> (one transaction, once). Images are then
<code>ipcr.localhost:4767/ipns/example.eth/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.</li>
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
<code>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
<code>ipcr.localhost:4767/ipns/example.com/&lt;owner&gt;/&lt;repo&gt;:1.0.0</code>.</li>
</ul>
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
<h2 id="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 &lt;id&gt;</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>
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
from this server and from every other server that already pulled it.</p>
<h3>On a Yundera server</h3>
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
<h3>On any Linux server with Docker 29+</h3>
<pre><code>mkdir -p /DATA/AppData/ipcr &amp;&amp; cd /DATA/AppData/ipcr
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
install -d -m 0700 -o 1000 -g 1000 kubo &amp;&amp; install -d -o 1000 -g 1000 init.d
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
chmod 0755 init.d/01-ipcr-node.sh
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' &gt; .env
docker compose up -d</code></pre>
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
<h2 id="addresses">Addresses on this server</h2>
<div class="scroll"><table>
<tr><th>Address</th><th>What</th></tr>
<tr><td><a id="t-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-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
</table></div>
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
</main>
<script>
(function () {
"use strict";
var host = location.hostname;
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
function setLink(id, prefix, text) {
var a = document.getElementById(id);
if (!a) return;
a.href = url(prefix);
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
}
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
document.getElementById("host").textContent = base || host;
setLink("link-gitea", "gitea");
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-api", "radicle-api", true);
function el(tag, text, cls) {
var e = document.createElement(tag);
if (text != null) e.textContent = text;
if (cls) e.className = cls;
return e;
}
function link(href, text) { var a = el("a", text); a.href = href; return a; }
function copyButton(text) {
var b = el("button", "Copy", "copy");
b.type = "button";
b.addEventListener("click", function () {
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
} else { window.prompt("Copy:", text); }
});
return b;
}
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
function cmpTags(a, b) {
if (a === "latest") return -1;
if (b === "latest") return 1;
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
var x = pa[i] || "", y = pb[i] || "";
var nx = parseInt(x, 10), ny = parseInt(y, 10);
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
if (x !== y) return x < y ? 1 : -1;
}
return 0;
}
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 };
}
// Published images: IPCR's published.json.
var imagesBody = document.getElementById("images-body");
fetch("images.json", { cache: "no-store" })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (pub) {
var reg = pub.registry || "ipcr.localhost:4767";
if (pub.publisher) {
document.getElementById("publisher-name").textContent = pub.publisher;
all("span.publisher", function (e) { e.textContent = pub.publisher; });
}
var repos = Object.keys(pub.images || {}).sort();
if (!repos.length) throw new Error("empty");
imagesBody.textContent = "";
repos.forEach(function (repo) {
var r = pub.images[repo];
imagesBody.appendChild(el("h3", repo));
var tb = table(["Tag", "Compose line", ""]);
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
var info = r.tags[tag];
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
var tr = el("tr");
var td1 = el("td"); td1.appendChild(el("code", tag));
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
var td2 = el("td"); td2.appendChild(el("code", line));
var pin = el("div", null, "muted");
pin.appendChild(document.createTextNode("this build only: "));
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
td2.appendChild(pin);
var td3 = el("td"); td3.appendChild(copyButton(line));
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tb.t.appendChild(tr);
});
imagesBody.appendChild(tb.wrap);
});
})
.catch(function () {
imagesBody.innerHTML = "";
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.
var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" })
.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) {
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
.catch(function () {});
}
})();
</script>
</body>
</html>