From ca8f87050b3055472a7ffbb1abfeb1c2ae3605a6 Mon Sep 17 00:00:00 2001
From: Worph
Date: Mon, 5 Oct 2026 16:11:24 +0000
Subject: [PATCH] Split listing support: Gitea and Radicle as separate apps
- setup: with RUNNER_DIR, fetch the runner registration token from Gitea's
admin API (POST /admin/actions/runners/registration-token) instead of the
gitea CLI on Gitea's volume; record secrets/setup-status (ok, unreachable,
bad-credentials) and exit cleanly when Gitea cannot be used.
- mirror: Radicle is optional. Repositories wait until a node is there (key and
control socket in RAD_HOME), re-checked every reconcile; RADICLE=off.
- page: /status.json; hide the Radicle parts without a node, banner when Gitea
is unreachable or refuses the login.
- example workflow: runs-on: ipcr, the forge's own runner label.
- docs: two listings, split (IPCR-Forge) and all in one (IPCR-Forge-AIO).
---
README.md | 13 ++--
docs/forge.md | 48 +++++++++++-
forge/bridge_test.go | 95 +++++++++++++++++++++++-
forge/example/.gitea/workflows/build.yml | 4 +-
forge/main.go | 19 +++--
forge/mirror.go | 27 +++++++
forge/setup.go | 67 ++++++++++++++++-
forge/state.go | 2 +-
forge/web.go | 27 ++++++-
forge/web/index.html | 42 +++++++++--
10 files changed, 317 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index e0092a1..3f1a335 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,12 @@ registry gateway, IPFS import, IPNS naming and admin API), used here as the imag
`ghcr.io/yundera/ipcr`.
- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified.
-- **[Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)** (in the IPCR repository, with the
- rest of the IPCR app store): the Yundera AppStore listing (compose, rationale, seed). It pins this
- repository's image and the engine's.
+- **[Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)** and
+ **[Apps/IPCR-Forge-AIO](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge-AIO)** (in the IPCR repository,
+ with the rest of the IPCR app store): the Yundera AppStore listings (compose, rationale, seed). The
+ first uses the store's Gitea app (and its Radicle app, if installed); the second bundles both.
+ Both pin this repository's image and the engine's. See
+ [docs/forge.md](docs/forge.md#two-listings-split-and-all-in-one).
- **[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.
@@ -33,5 +36,5 @@ 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)). Then bump the version in the
-listing, `Apps/IPCR-Forge/docker-compose.yml` in the IPCR repository (the service, `forge-gate`
-and the `forge-setup` step).
+listings, `Apps/IPCR-Forge/docker-compose.yml` and `Apps/IPCR-Forge-AIO/docker-compose.yml` in
+the IPCR repository (the service, `forge-gate` and the `forge-setup` step, in each).
diff --git a/docs/forge.md b/docs/forge.md
index e7d7974..0ccc254 100644
--- a/docs/forge.md
+++ b/docs/forge.md
@@ -6,9 +6,11 @@ naming, the admin API). The engine knows nothing about Gitea, Radicle or this fo
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 (store listing: [Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge), in the IPCR repository) 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
-[rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge/rationale.md).
+How the forge (store listings: [Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)
+and [Apps/IPCR-Forge-AIO](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge-AIO), in the IPCR repository)
+goes from `git push` to `docker pull`, why it is shaped this way, and what was learned building it.
+The listings' own security argument is in
+[rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge-AIO/rationale.md).
## Goal
@@ -60,6 +62,42 @@ anyone: docker pull ipcr.localhost:4767/ipns////:`.
## Findings
@@ -408,3 +448,5 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
- **Store install:** test through the store, not only as a hand install.
+- **Split listing:** the store has no dependencies between apps; removing the Gitea app leaves the
+ forge reporting `unreachable` on its page, nothing more.
diff --git a/forge/bridge_test.go b/forge/bridge_test.go
index b175dfe..5a83d25 100644
--- a/forge/bridge_test.go
+++ b/forge/bridge_test.go
@@ -180,7 +180,9 @@ func TestExampleEmbedded(t *testing.T) {
func TestWeb(t *testing.T) {
dir := t.TempDir()
st, _ := loadState(filepath.Join(dir, "repos.json"))
- h := webHandler(st, filepath.Join(dir, "published.json"))
+ h := webHandler(st, filepath.Join(dir, "published.json"), func() siteStatus {
+ return siteStatus{Gitea: readSetupStatus(dir), Radicle: false}
+ })
get := func(p string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
@@ -199,11 +201,102 @@ func TestWeb(t *testing.T) {
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
t.Errorf("/repos.json: %d %q", c, b)
}
+ if c, b := get("/status.json"); c != 200 || strings.TrimSpace(b) != `{"gitea":"unknown","radicle":false}` {
+ t.Errorf("/status.json before setup: %d %q", c, b)
+ }
+ writeSetupStatus(dir, setupBadCredentials)
+ if _, b := get("/status.json"); strings.TrimSpace(b) != `{"gitea":"bad-credentials","radicle":false}` {
+ t.Errorf("/status.json after setup: %q", b)
+ }
if c, _ := get("/nothing"); c != 404 {
t.Errorf("/nothing: %d", c)
}
}
+// fakeGitea answers the few API calls a test needs; anything else is a 404.
+func fakeGitea(t *testing.T, routes map[string]http.HandlerFunc) *httptest.Server {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h, ok := routes[r.Method+" "+r.URL.Path]; ok {
+ h(w, r)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// Without a Radicle node, a public repository waits (and nothing is fetched); with one, the mirror
+// would run (radicleReady is what gates it).
+func TestSyncWithoutRadicle(t *testing.T) {
+ dir := t.TempDir()
+ srv := fakeGitea(t, map[string]http.HandlerFunc{
+ "GET /api/v1/repositories/7": func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`{"id":7,"name":"app","full_name":"metadec/app","default_branch":"main"}`))
+ },
+ })
+ st, _ := loadState(filepath.Join(dir, "repos.json"))
+ rad := filepath.Join(dir, "radicle-home")
+ m := &mirror{gitea: newGitea(srv.URL, "gitea_admin", filepath.Join(dir, "token")), state: st,
+ repoDir: filepath.Join(dir, "repos"), radHome: rad}
+ m.sync(7)
+ got := st.get(7)
+ if got.State != stWaiting || got.Error != errNoRadicle || got.FullName != "metadec/app" {
+ t.Errorf("without Radicle: %+v", got)
+ }
+ if _, err := os.Stat(m.repoDir); err == nil {
+ t.Error("fetched a mirror with no Radicle node to push it to")
+ }
+
+ os.MkdirAll(filepath.Join(rad, "keys"), 0o755)
+ os.WriteFile(filepath.Join(rad, "keys", "radicle"), []byte("k"), 0o600)
+ if m.radicleReady() {
+ t.Error("ready with a key but no running node")
+ }
+ os.MkdirAll(filepath.Join(rad, "node"), 0o755)
+ os.WriteFile(filepath.Join(rad, "node", "control.sock"), nil, 0o600)
+ if !m.radicleReady() {
+ t.Error("not ready with a key and a control socket")
+ }
+ m.radicleOff = true
+ if m.radicleReady() {
+ t.Error("RADICLE=off ignored")
+ }
+}
+
+func TestRunnerToken(t *testing.T) {
+ dir := t.TempDir()
+ calls := 0
+ srv := fakeGitea(t, map[string]http.HandlerFunc{
+ "POST /api/v1/admin/actions/runners/registration-token": func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ if u, p, ok := r.BasicAuth(); !ok || u != "gitea_admin" || p != "pw" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ w.Write([]byte(`{"token":"reg-123"}`))
+ },
+ })
+ g := newGitea(srv.URL, "gitea_admin", filepath.Join(dir, "gitea-token"))
+ g.password = "pw"
+ if err := ensureRunnerToken(g, dir); err != nil {
+ t.Fatal(err)
+ }
+ if b, _ := os.ReadFile(filepath.Join(dir, "token")); string(b) != "reg-123" {
+ t.Errorf("token file %q", b)
+ }
+ // Registered: left alone.
+ os.WriteFile(filepath.Join(dir, ".runner"), []byte(`{"id":1}`), 0o600)
+ if err := ensureRunnerToken(g, dir); err != nil || calls != 1 {
+ t.Errorf("registered runner: err %v, %d calls", err, calls)
+ }
+ g.password = "wrong"
+ os.Remove(filepath.Join(dir, ".runner"))
+ if err := ensureRunnerToken(g, dir); err == nil {
+ t.Error("refused token request not reported")
+ }
+}
+
// The same vector as gateway/admin_test.go: a Kubo key and its name, and a backup of it that ipcrd
// must be able to open.
const (
diff --git a/forge/example/.gitea/workflows/build.yml b/forge/example/.gitea/workflows/build.yml
index 5dc8427..f95ede8 100644
--- a/forge/example/.gitea/workflows/build.yml
+++ b/forge/example/.gitea/workflows/build.yml
@@ -7,13 +7,15 @@
# the forge sets on every repository allowed to publish
# images: localhost:5000/… this repository's own name there (no other is accepted)
# network=host so buildx's builder can reach that registry
+# runs-on: ipcr the forge's own runner, the one that sees that registry (a Gitea app's
+# own runner, if there is one, does not)
name: image
on:
push:
tags: ['v*']
jobs:
image:
- runs-on: ubuntu-latest
+ runs-on: ipcr
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
diff --git a/forge/main.go b/forge/main.go
index c927369..fd7772a 100644
--- a/forge/main.go
+++ b/forge/main.go
@@ -22,7 +22,11 @@
// 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 /forge)
-// RAD_HOME the node's Radicle home (default /radicle-home)
+// RAD_HOME the node's Radicle home (default /radicle-home). Optional: without a node
+// there (no key, or no control socket) nothing is mirrored until one appears
+// RADICLE off: never mirror to Radicle (default auto)
+// RUNNER_DIR setup only: act_runner's data folder; when set and the runner is not
+// registered, setup leaves a registration token there (the split listing)
// 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)
@@ -111,10 +115,11 @@ func serve() error {
}
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"),
+ 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"),
+ radicleOff: env("RADICLE", "auto") == "off",
}
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
return err
@@ -129,7 +134,9 @@ func serve() error {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
- mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"))
+ mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"), func() siteStatus {
+ return siteStatus{Gitea: readSetupStatus(secrets), Radicle: m.radicleReady()}
+ })
if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil {
diff --git a/forge/mirror.go b/forge/mirror.go
index 34abdaa..189939b 100644
--- a/forge/mirror.go
+++ b/forge/mirror.go
@@ -21,6 +21,8 @@ type mirror struct {
state *state
repoDir string // one bare repository per Gitea repository: .git
radHome string
+ // RADICLE=off: never mirror, whatever is mounted.
+ radicleOff bool
// Called after each reconcile pass (the admin pages keep IPCR's allowlist in step).
onReconcile func()
}
@@ -56,6 +58,15 @@ func (m *mirror) sync(id int64) {
}
return
}
+ if !m.radicleReady() {
+ // No node (the split listing without the Radicle app, or the node not started yet). Nothing
+ // is fetched; the reconcile loop looks again, so installing Radicle later starts the mirror.
+ if cur.State != stWaiting || cur.Error != errNoRadicle {
+ cur.State, cur.Error = stWaiting, errNoRadicle
+ 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)
@@ -65,6 +76,22 @@ func (m *mirror) sync(id int64) {
m.save(cur)
}
+const errNoRadicle = "no Radicle node: mirrored once the Radicle app is installed and running"
+
+// radicleReady reports whether a Radicle node is there to mirror into: its key (rad auth ran) and
+// its control socket (the node runs) in RAD_HOME.
+func (m *mirror) radicleReady() bool {
+ if m.radicleOff {
+ return false
+ }
+ for _, p := range []string{"keys/radicle", "node/control.sock"} {
+ if _, err := os.Stat(filepath.Join(m.radHome, p)); err != nil {
+ return false
+ }
+ }
+ return true
+}
+
func (m *mirror) save(r repoState) {
if err := m.state.put(r); err != nil {
log.Printf("state: %v", err)
diff --git a/forge/setup.go b/forge/setup.go
index 743d98b..ab826ad 100644
--- a/forge/setup.go
+++ b/forge/setup.go
@@ -5,6 +5,7 @@ import (
"embed"
"encoding/base64"
"encoding/hex"
+ "encoding/json"
"errors"
"fmt"
"io/fs"
@@ -58,9 +59,16 @@ func ensureOrg(g *gitea, org string) error {
// the root organisation when there is one.
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
// SECRETS_DIR/oauth-client-id and oauth-client-secret.
+// RUNNER_DIR/token when RUNNER_DIR is set and the runner there is not registered yet: a
+// global runner registration token (the split listing, which cannot run
+// `gitea actions generate-runner-token` on Gitea's own volume).
+// SECRETS_DIR/setup-status how the last run went with Gitea (ok, unreachable, bad-credentials),
+// for the forge's page.
//
// 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.
+// step leaves the existing token and hook alone instead of failing the start. When Gitea cannot be
+// used at all (not installed, or the password is wrong and there is no token yet), the step records
+// why and exits cleanly: the page says what to do, and the next start tries again.
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"))
@@ -68,6 +76,11 @@ func setup() error {
if g.password == "" {
return errors.New("GITEA_PASSWORD is required")
}
+ status := func(s string) {
+ if err := writeSetupStatus(secrets, s); err != nil {
+ log.Printf("setup: status: %v", err)
+ }
+ }
// 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.
@@ -79,12 +92,20 @@ func setup() error {
}
if code == http.StatusUnauthorized && g.hasToken() {
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
+ status(setupOK)
return nil
}
time.Sleep(3 * time.Second)
}
if err != nil {
- return fmt.Errorf("Gitea not ready after 240s: %w", err)
+ if code == http.StatusUnauthorized {
+ log.Printf("setup: Gitea refuses %s's password, and there is no token yet: set the forge's Gitea admin credentials (%v)", g.owner, err)
+ status(setupBadCredentials)
+ } else {
+ log.Printf("setup: Gitea not reachable at %s after 240s, is the Gitea app installed? (%v)", g.base, err)
+ status(setupUnreachable)
+ }
+ return nil
}
if !g.hasToken() {
@@ -153,12 +174,54 @@ func setup() error {
}
}
+ if dir := os.Getenv("RUNNER_DIR"); dir != "" {
+ if err := ensureRunnerToken(g, dir); err != nil {
+ return fmt.Errorf("runner registration token: %w", err)
+ }
+ }
+
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example.
if err := ensureExample(g, owner, name); err != nil {
log.Printf("setup: example repository: %v", err)
}
}
+ status(setupOK)
+ return nil
+}
+
+// What the last setup run found, in SECRETS_DIR/setup-status (read by the page, see web.go).
+const (
+ setupOK = "ok"
+ setupUnreachable = "unreachable"
+ setupBadCredentials = "bad-credentials"
+)
+
+func writeSetupStatus(secrets, s string) error {
+ b, _ := json.Marshal(map[string]string{"gitea": s})
+ return writeSecret(filepath.Join(secrets, "setup-status"), string(b))
+}
+
+// ensureRunnerToken leaves a global runner registration token in dir/token for act_runner, unless
+// the runner there is already registered (dir/.runner). Gitea returns the same token until it is
+// used, so a repeated call before registration is harmless.
+func ensureRunnerToken(g *gitea, dir string) error {
+ if b, err := os.ReadFile(filepath.Join(dir, ".runner")); err == nil && len(b) > 0 {
+ return nil
+ }
+ var t struct {
+ Token string `json:"token"`
+ }
+ if _, err := g.call("POST", "/admin/actions/runners/registration-token", nil, &t); err != nil {
+ return err
+ }
+ if t.Token == "" {
+ return errors.New("Gitea returned no token")
+ }
+ if err := writeSecret(filepath.Join(dir, "token"), t.Token); err != nil {
+ return err
+ }
+ log.Printf("setup: runner registration token written")
return nil
}
diff --git a/forge/state.go b/forge/state.go
index 55d9440..f58f4d4 100644
--- a/forge/state.go
+++ b/forge/state.go
@@ -15,7 +15,7 @@ import (
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
+ stWaiting = "waiting" // empty repository, or no Radicle node yet: nothing to publish to
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
)
diff --git a/forge/web.go b/forge/web.go
index 375a1a8..2e7167e 100644
--- a/forge/web.go
+++ b/forge/web.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"os"
+ "path/filepath"
)
//go:embed web
@@ -13,7 +14,7 @@ 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.ServeMux {
+func webHandler(st *state, publishedFile string, status func() siteStatus) *http.ServeMux {
mux := http.NewServeMux()
page, _ := webFiles.ReadFile("web/index.html")
icon, _ := webFiles.ReadFile("web/icon.png")
@@ -42,12 +43,36 @@ func webHandler(st *state, publishedFile string) *http.ServeMux {
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(st.list())
})
+ mux.HandleFunc("GET /status.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(status())
+ })
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n"))
})
return mux
}
+// siteStatus is what the page needs to know about the apps the forge relies on: Gitea (as the
+// setup step last found it) and whether a Radicle node is there to mirror into.
+type siteStatus struct {
+ Gitea string `json:"gitea"` // ok | unreachable | bad-credentials | unknown (setup has not run)
+ Radicle bool `json:"radicle"`
+}
+
+// readSetupStatus reads what the setup step left in SECRETS_DIR/setup-status.
+func readSetupStatus(secrets string) string {
+ var s struct {
+ Gitea string `json:"gitea"`
+ }
+ b, err := os.ReadFile(filepath.Join(secrets, "setup-status"))
+ if err != nil || json.Unmarshal(b, &s) != nil || s.Gitea == "" {
+ return "unknown"
+ }
+ return s.Gitea
+}
+
// adminRoutes adds the admin pages: the page itself (signed-in Gitea admins only), the login, and
// the API it calls.
func adminRoutes(mux *http.ServeMux, au *auth, adm *adminAPI) {
diff --git a/forge/web/index.html b/forge/web/index.html
index d39a97a..240a535 100644
--- a/forge/web/index.html
+++ b/forge/web/index.html
@@ -67,6 +67,8 @@
.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); }
+ [hidden] { display: none !important; }
+ .banner { border: 1px solid var(--bad); border-radius: 8px; padding: 10px 14px; margin: 16px 0; }
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
@@ -83,11 +85,12 @@
From git push to docker pull 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.
+
How it works
@@ -102,7 +105,7 @@ mirrored to the Radicle peer-to-peer network.
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.
-Alongside, every public repository is copied to Radicle
+
Alongside, every public repository is copied to Radicle
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 rad clone it,
and it stays available even when this server is not. Private repositories are never copied.
@@ -131,7 +134,8 @@ git push -u origin main
Applications).
Add a workflow at .gitea/workflows/build.yml. This one builds the
Dockerfile at the root for every v* tag. It is a GitHub-style
- workflow with three differences: it logs in to localhost:5000, this forge's staging
+ workflow with four differences: it runs on ipcr, this forge's own runner (the
+ only one that reaches the staging registry), it logs in to localhost:5000, this forge's staging
registry, as the repository itself (the forge sets the IPCR_PUSH_TOKEN secret on
every public repository allowed to publish), the image goes there under the repository's own
name, and buildx's builder runs with network=host so it can reach that registry.
@@ -141,7 +145,7 @@ on:
tags: ['v*']
jobs:
image:
- runs-on: ubuntu-latest
+ runs-on: ipcr
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
@@ -200,7 +204,10 @@ image's content address and can never change.
New releases need no change to either record: they update the publisher name.
On Radicle
-Public repositories of this forge, as mirrored to the Radicle network by this server's node
+
No Radicle node on this server, so nothing is mirrored yet.
+Install the Radicle app from the store: every public repository is then mirrored within one
+reconcile (ten minutes at most).
+Public repositories of this forge, as mirrored to the Radicle network by this server's node
(…). Clone one with rad clone <id> from any Radicle node.
A repository that turns private or is deleted in Gitea is no longer synced
@@ -235,8 +242,8 @@ for IPFS peers. Details:
Gitea: repositories, Actions, users (gitea_admin) |
| This page (public) |
- | Radicle explorer (public) |
- | Radicle API and read-only git over HTTPS (public) |
+ | Radicle explorer (public) |
+ | Radicle API and read-only git over HTTPS (public) |
ipcr.localhost:4767 | IPCR registry, for this server's Docker only |
| Public front door: docker pull this forge's images from anywhere (read-only) |
| TCP 8776 · TCP/UDP 4768 | Radicle peers · IPFS peers |
@@ -363,6 +370,27 @@ for IPFS peers. Details: