Split listing support: Gitea and Radicle as separate apps
image / vet (push) Successful in 1m51s
image / image (push) Failing after 29s

- 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).
This commit is contained in:
Worph
2026-10-05 16:11:24 +00:00
parent eb80042f13
commit ca8f87050b
10 changed files with 317 additions and 27 deletions
+8 -5
View File
@@ -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).
+45 -3
View File
@@ -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/<forge k51… | example.eth>/<owner
The two legs do not depend on each other: a repository can be built without being mirrored
(private repositories), and mirrored without being built (no workflow).
## Two listings: split and all in one
The same image runs in both; only the compose differs.
| | **IPCR-Forge** (split) | **IPCR-Forge-AIO** (all in one) |
| --- | --- | --- |
| Gitea | the store's **Gitea** app, required, reached at `gitea:3000` on `pcs` | bundled |
| Radicle | the store's **Radicle** app, optional, its home mounted from `/DATA/AppData/radicle/home` | bundled |
| Runner labels | `ipcr` only | `ipcr` and `ubuntu-latest`/`-24.04`/`-22.04` |
| Runner registration token | the setup step, through Gitea's API (`RUNNER_DIR`) | an init step on Gitea's volume |
| Beside the Gitea/Radicle apps | yes, that is the point | no: same container names, domains, ports |
Neither sits beside the IPCR app (the engine is bundled in both: its configuration is the forge's).
What the split one relies on, and how each part fails when missing:
- **Gitea**: the forge service calls `http://gitea:3000` and Gitea calls the forge's webhook at
`http://ipcr-forge:8081`, which the Gitea listing allows (`webhook.ALLOWED_HOST_LIST:
external,ipcr-forge`). The setup step signs in once as `FORGE_GITEA_ADMIN` / `FORGE_GITEA_PASSWORD`
(default `gitea_admin` and the server's default password). Gitea mints tokens only for a password,
never for another token, so these are what to change if the password was changed. Setup leaves
`secrets/setup-status` (`ok`, `unreachable`, `bad-credentials`), and the page shows a banner
for the last two. Setup exits cleanly in either case, so the install never hangs on a missing Gitea.
- **The runner**: the Gitea app has its own runner, `gitea-runner`, with the usual labels. Its jobs
run in its own daemon, where `localhost:5000` is nothing. So the forge's runner takes `ipcr` only,
and publishing workflows say `runs-on: ipcr` (the example does; it works in both listings).
- **Radicle**: the forge service needs the node's home itself (its key, its storage, its control
socket), not an API: Radicle's HTTP API is read-only. A repository is mirrored only when
`keys/radicle` and `node/control.sock` are there; otherwise it shows *waiting* and the page hides
the Radicle parts. The reconcile loop looks again every pass, so installing Radicle later starts
the mirror. `RADICLE=off` turns it off for good. The split listing creates
`/DATA/AppData/radicle/home` itself, owned by `$PUID`: left to Docker it would be created as
root, and the Radicle app's `rad auth` (as `$PUID`) could not write its key there later.
- **Versions**: the forge image carries the `rad` CLI at the version the Radicle listing pins
(`RADICLE_VERSION` in [forge/Dockerfile](../forge/Dockerfile)). Bump both together.
## The forge service
[forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`.
@@ -248,6 +286,8 @@ A repository opts in by committing a workflow under `.gitea/workflows/`. The tem
- `images: localhost:5000/${{ github.repository }}`: the repository's own name at the staging
registry, the only one its token may push. `docker/metadata-action` lowercases it.
- `context: .` and `driver-opts: network=host`: see findings 2 and 3.
- `runs-on: ipcr`: the forge's own runner, the only one whose jobs reach the staging registry (see
[Two listings](#two-listings-split-and-all-in-one)).
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
## 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.
+94 -1
View File
@@ -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 (
+3 -1
View File
@@ -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
+13 -6
View File
@@ -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 {
+27
View File
@@ -21,6 +21,8 @@ type mirror struct {
state *state
repoDir string // one bare repository per Gitea repository: <id>.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)
+65 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
)
+26 -1
View File
@@ -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) {
+35 -7
View File
@@ -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; }
</style>
</head>
@@ -83,11 +85,12 @@
<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="banner" id="status-banner" hidden></div>
<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>
<a class="card" href="#repos" data-radicle><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
</div>
<h2 id="how">How it works</h2>
@@ -102,7 +105,7 @@ mirrored to the Radicle peer-to-peer network.</p>
</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>
<p data-radicle>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>
@@ -131,7 +134,8 @@ git push -u origin main</code></pre>
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 three differences: it logs in to <code>localhost:5000</code>, this forge's staging
workflow with four differences: it runs on <code>ipcr</code>, this forge's own runner (the
only one that reaches the staging registry), it logs in to <code>localhost:5000</code>, this forge's staging
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
every public repository allowed to publish), the image goes there under the repository's own
name, and buildx's builder runs with <code>network=host</code> 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.</p>
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
<h2 id="repos">On Radicle</h2>
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node
<p id="no-radicle" class="note" hidden>No Radicle node on this server, so nothing is mirrored yet.
Install the <b>Radicle</b> app from the store: every public repository is then mirrored within one
reconcile (ten minutes at most).</p>
<p data-radicle>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
@@ -235,8 +242,8 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<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 data-radicle><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
<tr data-radicle><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><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
@@ -363,6 +370,27 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
});
// What the forge relies on: Gitea (as the setup step last found it), and a Radicle node to mirror
// into. The split listing installs neither, so either may be missing.
fetch("status.json", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (st) {
if (!st.radicle) {
all("[data-radicle]", function (e) { e.hidden = true; });
document.getElementById("no-radicle").hidden = false;
}
var msg = {
"unreachable": "Gitea is not reachable. This forge needs the Gitea app on this server: install it, then restart IPCR Forge.",
"bad-credentials": "Gitea refused the forge's admin login (gitea_admin and the server's default password, unless changed in the app's settings). Set FORGE_GITEA_ADMIN and FORGE_GITEA_PASSWORD to a Gitea site admin, then restart IPCR Forge."
}[st.gitea];
if (msg) {
var b = document.getElementById("status-banner");
b.textContent = msg;
b.hidden = false;
}
})
.catch(function () {});
// Radicle mirrors: the forge service's state.
var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" })