IPCR Forge v3: Gitea as the source of truth, public repos mirrored to Radicle
Users found Radicle-as-source unfriendly (v1, v2). Gitea is now where people work; its Actions build images into the staging registry IPCR follows, as in v2. - bridge/: ipcr-forge-bridge (Go). Install step mints a public-only token, registers one system webhook and creates the ipcr-hello example; the service mirrors every public repo one way to Radicle (rad init, canonical tags rule, git push rad) and serves the forge's page. - Compose: gitea + act_runner on a separate dind; tile opens Gitea; tips link the forge page. - Removed the Radicle CI broker (ci/) and the nginx landing page.
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Everything Radicle is done with the stock `rad` CLI and `git-remote-rad`, against the node's own
|
||||
// home: the repository is created and signed by the node's key (its only delegate), written into
|
||||
// the node's storage, and announced through the node's control socket. The key has no passphrase
|
||||
// (the install step creates it that way), so nothing prompts.
|
||||
|
||||
var ridRe = regexp.MustCompile(`rad:z[1-9A-HJ-NP-Za-km-z]+`)
|
||||
|
||||
func (m *mirror) env() []string {
|
||||
return append(os.Environ(),
|
||||
"RAD_HOME="+m.radHome,
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
// rad's output is read by people in the container log, not by a terminal.
|
||||
"NO_COLOR=1",
|
||||
// Identity documents are git commits; git wants a name even when rad signs them.
|
||||
"GIT_AUTHOR_NAME=ipcr-forge", "GIT_AUTHOR_EMAIL=ipcr-forge@localhost",
|
||||
"GIT_COMMITTER_NAME=ipcr-forge", "GIT_COMMITTER_EMAIL=ipcr-forge@localhost",
|
||||
)
|
||||
}
|
||||
|
||||
// run executes a command in dir (when set) and returns its stdout; on failure the error carries
|
||||
// the end of stderr, which is where git and rad explain themselves.
|
||||
func (m *mirror) run(timeout time.Duration, dir, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = m.env()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if len(msg) > 500 {
|
||||
msg = "…" + msg[len(msg)-500:]
|
||||
}
|
||||
// Never echo an argument list: a fetch carries the Gitea token in one.
|
||||
return stdout.String(), fmt.Errorf("%s %s: %v: %s", name, args[0], err, msg)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func (m *mirror) git(dir string, args ...string) (string, error) {
|
||||
return m.run(10*time.Minute, dir, "git", args...)
|
||||
}
|
||||
|
||||
func (m *mirror) rad(dir string, args ...string) (string, error) {
|
||||
return m.run(2*time.Minute, dir, "rad", args...)
|
||||
}
|
||||
|
||||
// radInit creates the Radicle repository for r from its bare mirror and returns its RID. It also
|
||||
// makes this node seed it (the node's policy is `block`: nothing is kept unless asked) and adds
|
||||
// the `rad` remote that pushes go to.
|
||||
func (m *mirror) radInit(dir string, r *repo) (string, error) {
|
||||
desc := r.Description
|
||||
if desc == "" {
|
||||
desc = "Mirrored from " + r.HTMLURL
|
||||
}
|
||||
out, err := m.rad(dir, "init", dir, "--name", r.Name, "--description", desc,
|
||||
"--default-branch", r.DefaultBranch, "--public", "--no-confirm")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rid := m.remoteRID(dir); rid != "" {
|
||||
return rid, nil
|
||||
}
|
||||
if rid := ridRe.FindString(out); rid != "" {
|
||||
return rid, nil
|
||||
}
|
||||
return "", fmt.Errorf("no repository ID in rad init's output")
|
||||
}
|
||||
|
||||
// remoteRID reads the RID from the mirror's `rad` remote (rad://z…), or "" when there is none.
|
||||
func (m *mirror) remoteRID(dir string) string {
|
||||
out, err := m.git(dir, "config", "--get", "remote.rad.url")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ridFromURL(out)
|
||||
}
|
||||
|
||||
func ridFromURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if !strings.HasPrefix(u, "rad://") {
|
||||
return ""
|
||||
}
|
||||
z, _, _ := strings.Cut(strings.TrimPrefix(u, "rad://"), "/")
|
||||
return "rad:" + z
|
||||
}
|
||||
|
||||
// addRemote recreates what rad init sets up: fetch from the repository, push into this node's
|
||||
// namespace of it.
|
||||
func (m *mirror) addRemote(dir, rid string) error {
|
||||
nid, err := m.rad("", "self", "--nid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z := strings.TrimPrefix(rid, "rad:")
|
||||
if _, err := m.git(dir, "remote", "add", "rad", "rad://"+z); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.git(dir, "config", "remote.rad.pushurl", "rad://"+z+"/"+strings.TrimSpace(nid))
|
||||
return err
|
||||
}
|
||||
|
||||
// radCanonicalTags adds the rule that makes a delegate's tags the repository's refs/tags/*. Without
|
||||
// it a tag exists only under the node's namespace, where `rad clone` and the explorer do not show
|
||||
// it. One delegate (this node), so a threshold of 1.
|
||||
func (m *mirror) radCanonicalTags(rid string) error {
|
||||
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Canonical tags",
|
||||
"--description", "Tags pushed by the forge are the repository's tags.",
|
||||
"--payload", "xyz.radicle.crefs", "rules", `{"refs/tags/*":{"threshold":1,"allow":"delegates"}}`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *mirror) radDefaultBranch(rid, branch string) error {
|
||||
_, err := m.rad("", "id", "update", "--repo", rid, "--no-confirm", "--title", "Default branch",
|
||||
"--description", "Follows the Gitea repository.",
|
||||
"--payload", "xyz.radicle.project", "defaultBranch", fmt.Sprintf("%q", branch))
|
||||
return err
|
||||
}
|
||||
|
||||
// announce tells the network about the new refs. Best effort: a node without peers is not an error,
|
||||
// and the node announces again on its own schedule.
|
||||
func (m *mirror) announce(rid string) {
|
||||
if _, err := m.rad("", "sync", rid, "--announce", "--timeout", "30s"); err != nil {
|
||||
log.Printf("announce %s: %v", rid, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user