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).
This commit is contained in:
+94
-1
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <id></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" })
|
||||
|
||||
Reference in New Issue
Block a user