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 +

    +

    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.

    Loading…

    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:4767IPCR registry, for this server's Docker only Public front door: docker pull this forge's images from anywhere (read-only) TCP 8776 · TCP/UDP 4768Radicle peers · IPFS peers @@ -363,6 +370,27 @@ for IPFS peers. Details: