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:
@@ -0,0 +1,37 @@
|
||||
# ipcr-forge-bridge: the Gitea → Radicle mirror and IPCR Forge's page. Our Go binary, plus git and
|
||||
# the stock Radicle CLI (`rad`, `git-remote-rad`) at the version the forge's node runs.
|
||||
# Build context: this folder (bridge/).
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ipcr-forge-bridge .
|
||||
|
||||
# Radicle's official static (musl) release, checked against its published digest.
|
||||
FROM alpine:3.22 AS radicle
|
||||
ARG RADICLE_VERSION=1.10.1
|
||||
ARG RADICLE_SHA256=9354f7f6241a88ddca1d41a080aa6337ddfc5dc682b8de78ab6d418b5782225a
|
||||
# Retried: release downloads intermittently fail.
|
||||
RUN apk add --no-cache xz \
|
||||
&& url="https://files.radicle.dev/releases/${RADICLE_VERSION}/radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl.tar.xz" \
|
||||
&& for i in 1 2 3 4 5; do wget -qO /tmp/radicle.tar.xz "$url" && break; sleep $((i * 5)); done \
|
||||
&& echo "${RADICLE_SHA256} /tmp/radicle.tar.xz" | sha256sum -c - \
|
||||
&& mkdir /out && tar -xJf /tmp/radicle.tar.xz -C /out --strip-components=2 \
|
||||
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/rad" \
|
||||
"radicle-${RADICLE_VERSION}-x86_64-unknown-linux-musl/bin/git-remote-rad"
|
||||
|
||||
FROM alpine:3.22
|
||||
ARG VERSION=dev
|
||||
LABEL org.opencontainers.image.title="ipcr-forge-bridge" \
|
||||
org.opencontainers.image.description="IPCR Forge: mirrors public Gitea repositories to Radicle, serves the forge's page" \
|
||||
org.opencontainers.image.source="https://github.com/Yundera/ipcr" \
|
||||
org.opencontainers.image.version="$VERSION"
|
||||
RUN apk add --no-cache ca-certificates git \
|
||||
# Mirrors and the node's storage belong to the service's uid, but a bind mount can make git see
|
||||
# another owner ("dubious ownership"); the storage is the node's own, so trust it.
|
||||
&& git config --system safe.directory '*'
|
||||
COPY --from=radicle /out/ /usr/local/bin/
|
||||
COPY --from=build /out/ipcr-forge-bridge /usr/local/bin/ipcr-forge-bridge
|
||||
ENV RAD_HOME=/radicle-home \
|
||||
HOME=/tmp
|
||||
# No entrypoint: the install step runs `sh -c '… ipcr-forge-bridge setup'` in this image.
|
||||
CMD ["ipcr-forge-bridge", "serve"]
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sign(secret, body string) string {
|
||||
m := hmac.New(sha256.New, []byte(secret))
|
||||
m.Write([]byte(body))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func TestValidSignature(t *testing.T) {
|
||||
body := `{"repository":{"id":7}}`
|
||||
if !validSignature([]byte("s3cret"), []byte(body), sign("s3cret", body)) {
|
||||
t.Error("good signature rejected")
|
||||
}
|
||||
for _, sig := range []string{"", "zz", sign("other", body), sign("s3cret", body+" ")} {
|
||||
if validSignature([]byte("s3cret"), []byte(body), sig) {
|
||||
t.Errorf("bad signature %q accepted", sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretFile := filepath.Join(dir, "hook-secret")
|
||||
var got []int64
|
||||
h := &hookHandler{secretFile: secretFile, enqueue: func(id int64) { got = append(got, id) }}
|
||||
post := func(body, sig string) int {
|
||||
r := httptest.NewRequest("POST", "/hooks/gitea", strings.NewReader(body))
|
||||
r.Header.Set("X-Gitea-Signature", sig)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
body := `{"ref":"refs/tags/v1.0.0","repository":{"id":42,"full_name":"a/b"}}`
|
||||
if c := post(body, sign("k", body)); c != http.StatusServiceUnavailable {
|
||||
t.Errorf("no secret yet: %d", c)
|
||||
}
|
||||
os.WriteFile(secretFile, []byte("k\n"), 0o600)
|
||||
if c := post(body, sign("x", body)); c != http.StatusUnauthorized {
|
||||
t.Errorf("bad signature: %d", c)
|
||||
}
|
||||
if c := post(body, sign("k", body)); c != http.StatusAccepted {
|
||||
t.Errorf("good delivery: %d", c)
|
||||
}
|
||||
if c := post(`{"sender":{}}`, sign("k", `{"sender":{}}`)); c != http.StatusNoContent {
|
||||
t.Errorf("no repository: %d", c)
|
||||
}
|
||||
if len(got) != 1 || got[0] != 42 {
|
||||
t.Errorf("enqueued %v, want [42]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueCoalesces(t *testing.T) {
|
||||
q := newQueue()
|
||||
for _, id := range []int64{3, 1, 3, 3, 2, 1} {
|
||||
q.add(id)
|
||||
}
|
||||
var order []int64
|
||||
for {
|
||||
id, ok := q.next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
order = append(order, id)
|
||||
}
|
||||
if len(order) != 3 || order[0] != 3 || order[1] != 1 || order[2] != 2 {
|
||||
t.Errorf("order %v, want [3 1 2]", order)
|
||||
}
|
||||
q.add(3) // popped, so it may be queued again
|
||||
if id, ok := q.next(); !ok || id != 3 {
|
||||
t.Error("re-adding a popped repository")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublic(t *testing.T) {
|
||||
r := repo{}
|
||||
if !r.public() {
|
||||
t.Error("plain repository")
|
||||
}
|
||||
for _, f := range []func(*repo){
|
||||
func(r *repo) { r.Private = true },
|
||||
func(r *repo) { r.Internal = true },
|
||||
func(r *repo) { r.Owner.Visibility = "limited" },
|
||||
func(r *repo) { r.Owner.Visibility = "private" },
|
||||
} {
|
||||
r := repo{}
|
||||
f(&r)
|
||||
if r.public() {
|
||||
t.Errorf("%+v counted as public", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIDFromURL(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o\n": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
|
||||
"rad://z2pnYhouQzbiTEmXW9bydgXFqjP1o/z6MkppQoVh2QKyQGkPzGgEaqZcXmbeBVhZseQ2cgxuMzA5gR": "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o",
|
||||
"https://example.com/x.git": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := ridFromURL(in); got != want {
|
||||
t.Errorf("ridFromURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := ridRe.FindString("Your Repository ID (RID) is rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o\nYou can"); got != "rad:z2pnYhouQzbiTEmXW9bydgXFqjP1o" {
|
||||
t.Errorf("ridRe: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushedRefs(t *testing.T) {
|
||||
out := "To rad://z2pn/z6Mk\n" +
|
||||
"=\trefs/heads/main:refs/heads/main\t[up to date]\n" +
|
||||
"*\trefs/tags/v1.0.0:refs/tags/v1.0.0\t[new tag]\n" +
|
||||
"-\t:refs/heads/old\t[deleted]\n" +
|
||||
"+\trefs/heads/dev:refs/heads/dev\t1111111...2222222 (forced update)\n" +
|
||||
"Done\n"
|
||||
got := strings.Join(pushedRefs(out), "; ")
|
||||
want := "tags/v1.0.0 [new tag]; old [deleted]; dev 1111111...2222222 (forced update)"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "repos.json")
|
||||
s, err := loadState(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.known(5) || s.get(5).ID != 5 {
|
||||
t.Fatal("empty state")
|
||||
}
|
||||
s.put(repoState{ID: 5, FullName: "b/two", RID: "rad:z2", State: stSynced, Refs: "abc", Crefs: true})
|
||||
s.put(repoState{ID: 9, FullName: "a/one", State: stWaiting})
|
||||
s2, err := loadState(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := s2.get(5); r.RID != "rad:z2" || r.Refs != "abc" || !r.Crefs || r.State != stSynced {
|
||||
t.Errorf("reloaded %+v", r)
|
||||
}
|
||||
l := s2.list()
|
||||
if len(l) != 2 || l[0].FullName != "a/one" || l[1].Refs != "" {
|
||||
t.Errorf("list %+v", l)
|
||||
}
|
||||
if ids := s2.ids(); len(ids) != 2 || ids[0] != 5 || ids[1] != 9 {
|
||||
t.Errorf("ids %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleEmbedded(t *testing.T) {
|
||||
for _, p := range []string{"example/Dockerfile", "example/.gitea/workflows/build.yml", "example/README.md"} {
|
||||
if _, err := exampleFiles.ReadFile(p); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
b, _ := exampleFiles.ReadFile("example/.gitea/workflows/build.yml")
|
||||
if !strings.Contains(string(b), "localhost:5000/${{ github.repository }}") || !strings.Contains(string(b), "network=host") {
|
||||
t.Error("example workflow lost its staging registry lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeb(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := loadState(filepath.Join(dir, "repos.json"))
|
||||
h := webHandler(st, filepath.Join(dir, "published.json"))
|
||||
get := func(p string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
if c, b := get("/"); c != 200 || !strings.Contains(b, "IPCR Forge") {
|
||||
t.Errorf("/: %d", c)
|
||||
}
|
||||
if c, _ := get("/images.json"); c != 404 {
|
||||
t.Errorf("/images.json before anything is published: %d", c)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "published.json"), []byte(`{"images":{}}`), 0o644)
|
||||
if c, b := get("/images.json"); c != 200 || b != `{"images":{}}` {
|
||||
t.Errorf("/images.json: %d %q", c, b)
|
||||
}
|
||||
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
|
||||
t.Errorf("/repos.json: %d %q", c, b)
|
||||
}
|
||||
if c, _ := get("/nothing"); c != 404 {
|
||||
t.Errorf("/nothing: %d", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Builds the Dockerfile at the root for every v* tag and pushes it to this forge's staging registry.
|
||||
# IPCR picks it up from there within a minute and publishes it on IPFS; the forge's page then
|
||||
# lists the exact `image:` line to use.
|
||||
#
|
||||
# Differences from a GitHub-hosted workflow, both needed on this forge:
|
||||
# images: localhost:5000/… the staging registry (no login, no secret)
|
||||
# network=host so buildx's builder can reach that registry
|
||||
name: image
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: localhost:5000/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,6 @@
|
||||
# A one-page web server: busybox httpd on port 8080, as an unprivileged user.
|
||||
FROM busybox:1.37
|
||||
COPY index.html /www/index.html
|
||||
USER 1000:1000
|
||||
EXPOSE 8080
|
||||
CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# ipcr-hello
|
||||
|
||||
The IPCR Forge example. A tag on this repository builds its image, IPCR publishes it on IPFS,
|
||||
and the repository is mirrored to Radicle.
|
||||
|
||||
## Try it
|
||||
|
||||
1. **Tag a release.** In Gitea: *Tags → New tag* (or *Releases → New release*), name it `v1.0.0`
|
||||
and target `main`. From a clone it works too:
|
||||
|
||||
```sh
|
||||
git tag v1.0.0 && git push origin v1.0.0
|
||||
```
|
||||
|
||||
2. **Watch the build** under *Actions*. The first build on a new server takes a few minutes more,
|
||||
while it downloads its build images.
|
||||
|
||||
3. **Find the image** on the forge's page (`ipcr-forge-<your domain>`, *Published images*), about
|
||||
a minute after the build finishes. Then on this server or on any other running IPCR:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hello:
|
||||
image: ipcr.localhost:4767/ipns/<forge name>/gitea_admin/ipcr-hello:1.0.0
|
||||
ports: ["8080:8080"]
|
||||
```
|
||||
|
||||
## What makes it work
|
||||
|
||||
- [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) pushes to `localhost:5000`, the
|
||||
forge's staging registry. IPCR imports every tag it finds there.
|
||||
- [`Dockerfile`](Dockerfile) is an ordinary Dockerfile. Anything that builds works.
|
||||
|
||||
Copy the workflow into any public or private repository on this forge to publish it the same way.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ipcr-hello</title></head>
|
||||
<body style="font: 18px/1.6 system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em">
|
||||
<h1>Hello from IPFS</h1>
|
||||
<p>This page is served by a container whose image was built by IPCR Forge and pulled from IPFS
|
||||
through IPCR.</p>
|
||||
</body>
|
||||
</html>
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gitea is a minimal Gitea API client. The daemon authenticates with the token in tokenFile (read
|
||||
// on every call: the setup step may write it after the service starts); the setup step with the
|
||||
// admin's password instead (Gitea's token API accepts nothing else).
|
||||
type gitea struct {
|
||||
base string
|
||||
owner string
|
||||
tokenFile string
|
||||
password string // setup only
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newGitea(base, owner, tokenFile string) *gitea {
|
||||
return &gitea{base: strings.TrimRight(base, "/"), owner: owner, tokenFile: tokenFile,
|
||||
http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (g *gitea) token() string {
|
||||
b, _ := os.ReadFile(g.tokenFile)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (g *gitea) hasToken() bool { return g.token() != "" }
|
||||
|
||||
// gitAuth is the header git sends to Gitea's HTTP endpoint: basic auth with the token as password,
|
||||
// passed with http.extraHeader so it never appears in a URL, `ps` or a log.
|
||||
func (g *gitea) gitAuth() string {
|
||||
return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(g.owner+":"+g.token()))
|
||||
}
|
||||
|
||||
// call sends a request and decodes a JSON answer into out (when non-nil and the status is 2xx).
|
||||
// It returns the status; a transport error is returned as an error with status 0.
|
||||
func (g *gitea) call(method, path string, body, out any) (int, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, g.base+"/api/v1"+path, rd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if g.password != "" {
|
||||
req.SetBasicAuth(g.owner, g.password)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "token "+g.token())
|
||||
}
|
||||
resp, err := g.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
msg := strings.TrimSpace(string(data))
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, msg)
|
||||
}
|
||||
if out != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
type repo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
Internal bool `json:"internal"`
|
||||
Fork bool `json:"fork"`
|
||||
Mirror bool `json:"mirror"`
|
||||
Empty bool `json:"empty"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
// public | limited | private. Only a public owner's repositories are public.
|
||||
Visibility string `json:"visibility"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
// public reports whether the repository may leave this server. The token is `public-only`, so
|
||||
// Gitea already hides everything else; this is the second lock on the same door.
|
||||
func (r *repo) public() bool {
|
||||
return !r.Private && !r.Internal && (r.Owner.Visibility == "" || r.Owner.Visibility == "public")
|
||||
}
|
||||
|
||||
// publicRepos lists every repository the token can see.
|
||||
func (g *gitea) publicRepos() ([]repo, error) {
|
||||
var all []repo
|
||||
for page := 1; ; page++ {
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []repo `json:"data"`
|
||||
}
|
||||
q := url.Values{"limit": {"50"}, "page": {fmt.Sprint(page)}}
|
||||
if _, err := g.call("GET", "/repos/search?"+q.Encode(), nil, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, res.Data...)
|
||||
if len(res.Data) < 50 {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repoByID returns the repository, or nil when the token cannot see it (private, deleted).
|
||||
func (g *gitea) repoByID(id int64) (*repo, error) {
|
||||
var r repo
|
||||
code, err := g.call("GET", fmt.Sprintf("/repositories/%d", id), nil, &r)
|
||||
if code == http.StatusNotFound || code == http.StatusForbidden {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module ipcr-forge-bridge
|
||||
|
||||
go 1.24
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// hookHandler receives Gitea's system webhook. It only learns *which* repository changed: the sync
|
||||
// itself re-reads everything from Gitea, so a payload is never trusted for content, and a burst of
|
||||
// deliveries for one repository collapses into one sync.
|
||||
type hookHandler struct {
|
||||
secretFile string
|
||||
enqueue func(id int64)
|
||||
}
|
||||
|
||||
func (h *hookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
secret, _ := os.ReadFile(h.secretFile)
|
||||
if len(strings.TrimSpace(string(secret))) == 0 {
|
||||
http.Error(w, "not configured yet", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !validSignature([]byte(strings.TrimSpace(string(secret))), body, r.Header.Get("X-Gitea-Signature")) {
|
||||
log.Printf("hook: bad signature from %s (event %q)", r.RemoteAddr, r.Header.Get("X-Gitea-Event"))
|
||||
http.Error(w, "bad signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Repository *struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &p); err != nil || p.Repository == nil || p.Repository.ID == 0 {
|
||||
// Signed, but about no repository (a user or organisation event): nothing to do.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
h.enqueue(p.Repository.ID)
|
||||
// Answered at once: Gitea gives a delivery 5 seconds, a sync can take minutes.
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// validSignature checks Gitea's X-Gitea-Signature: hex HMAC-SHA256 of the raw body, no prefix.
|
||||
func validSignature(secret, body []byte, sig string) bool {
|
||||
got, err := hex.DecodeString(strings.TrimSpace(sig))
|
||||
if err != nil || len(got) == 0 {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(body)
|
||||
return hmac.Equal(got, mac.Sum(nil))
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// ipcr-forge-bridge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
|
||||
// is replicated, and this service keeps the second following the first. One way only.
|
||||
//
|
||||
// serve (default) webhook receiver + mirror worker + the forge's page
|
||||
// setup install step: Gitea token, system webhook, example repository (idempotent)
|
||||
// health exit 0 when `serve` answers, for the container healthcheck
|
||||
//
|
||||
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
|
||||
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
|
||||
// Each one is `rad init`ed by this node on first sight (the node's key is its only delegate) and
|
||||
// then receives its branches and tags with `git push rad`. A repository that turns private or is
|
||||
// deleted is frozen: no further sync, and nothing is taken back from the network (it cannot be).
|
||||
//
|
||||
// Triggers: Gitea's system webhook (push, create, delete, repository) for latency, and a full
|
||||
// reconcile every RECONCILE for whatever a lost delivery, a rename or a visibility change missed.
|
||||
//
|
||||
// Environment:
|
||||
//
|
||||
// GITEA_URL Gitea, internal address (default http://gitea:3000)
|
||||
// GITEA_OWNER the admin account (default gitea_admin)
|
||||
// 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 /bridge)
|
||||
// RAD_HOME the node's Radicle home (default /radicle-home)
|
||||
// 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)
|
||||
// EXAMPLE_REPO setup only: example repository to create under GITEA_OWNER; empty = none
|
||||
// (default ipcr-hello)
|
||||
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
|
||||
// (default /srv/ipcr-state/published.json)
|
||||
// RECONCILE full pass interval (default 10m)
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
cmd := "serve"
|
||||
if len(os.Args) > 1 {
|
||||
cmd = os.Args[1]
|
||||
}
|
||||
var err error
|
||||
switch cmd {
|
||||
case "serve":
|
||||
err = serve()
|
||||
case "setup":
|
||||
err = setup()
|
||||
case "health":
|
||||
err = health()
|
||||
default:
|
||||
err = fmt.Errorf("usage: %s serve | setup | health", os.Args[0])
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func health() error {
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
r, err := c.Get("http://127.0.0.1" + env("WEB_LISTEN", ":8080") + "/healthz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("healthz: %s", r.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serve() error {
|
||||
every, err := time.ParseDuration(env("RECONCILE", "10m"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("RECONCILE: %w", err)
|
||||
}
|
||||
stateDir := env("STATE_DIR", "/bridge")
|
||||
st, err := loadState(stateDir + "/repos.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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"),
|
||||
}
|
||||
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
q := newQueue()
|
||||
go q.run(m.sync)
|
||||
go reconcileLoop(m, q, every)
|
||||
|
||||
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
|
||||
hmux := http.NewServeMux()
|
||||
hmux.Handle("POST /hooks/gitea", hook)
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
|
||||
}()
|
||||
|
||||
log.Printf("bridge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
|
||||
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")))
|
||||
}
|
||||
|
||||
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
|
||||
// that turned private or was deleted is noticed and frozen). The first pass runs at start-up.
|
||||
func reconcileLoop(m *mirror, q *queue, every time.Duration) {
|
||||
for {
|
||||
if !m.gitea.hasToken() {
|
||||
// The setup step runs after the stack is up, so on a first install this is normal.
|
||||
log.Printf("reconcile: waiting for the Gitea token (written by the setup step)")
|
||||
time.Sleep(15 * time.Second)
|
||||
continue
|
||||
}
|
||||
repos, err := m.gitea.publicRepos()
|
||||
if err != nil {
|
||||
// Gitea restarting, typically: try again soon rather than in a full interval.
|
||||
log.Printf("reconcile: %v", err)
|
||||
time.Sleep(time.Minute)
|
||||
continue
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, r := range repos {
|
||||
seen[r.ID] = true
|
||||
q.add(r.ID)
|
||||
}
|
||||
for _, id := range m.state.ids() {
|
||||
if !seen[id] {
|
||||
q.add(id)
|
||||
}
|
||||
}
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What is copied, both ways of the bare mirror: branches and tags. Not refs/pull/* (Gitea's PR
|
||||
// heads), not notes or anything else. Branches and tags are forced: Gitea is the truth, and a
|
||||
// force push or a moved tag there is mirrored as such. --prune makes deletions follow too.
|
||||
var refspecs = []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
|
||||
|
||||
type mirror struct {
|
||||
gitea *gitea
|
||||
state *state
|
||||
repoDir string // one bare repository per Gitea repository: <id>.git
|
||||
radHome string
|
||||
}
|
||||
|
||||
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
|
||||
func (m *mirror) sync(id int64) {
|
||||
prev := m.state.get(id)
|
||||
r, err := m.gitea.repoByID(id)
|
||||
if err != nil {
|
||||
log.Printf("sync %d: %v", id, err)
|
||||
return
|
||||
}
|
||||
if r == nil || !r.public() {
|
||||
// Private now, or deleted. Only a repository that was mirrored has anything to record.
|
||||
if m.state.known(id) && prev.State != stFrozen {
|
||||
prev.State = stFrozen
|
||||
prev.Error = "no longer public in Gitea: not synced any more (what was published stays on the Radicle network)"
|
||||
m.save(prev)
|
||||
log.Printf("sync %s: frozen, no longer public", prev.FullName)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Fork || r.Mirror {
|
||||
// Did not originate here: a fork's upstream or a mirror's source is the one to publish.
|
||||
return
|
||||
}
|
||||
cur := prev
|
||||
cur.FullName, cur.HTMLURL = r.FullName, r.HTMLURL
|
||||
if r.Empty {
|
||||
if cur.RID == "" {
|
||||
cur.State, cur.Error = stWaiting, ""
|
||||
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)
|
||||
} else {
|
||||
cur.State, cur.Error, cur.LastSync = stSynced, "", time.Now().UTC()
|
||||
}
|
||||
m.save(cur)
|
||||
}
|
||||
|
||||
func (m *mirror) save(r repoState) {
|
||||
if err := m.state.put(r); err != nil {
|
||||
log.Printf("state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publish fetches the repository from Gitea into its bare mirror, creates its Radicle repository on
|
||||
// first sight, and pushes what changed.
|
||||
func (m *mirror) publish(r *repo, cur *repoState) error {
|
||||
dir := filepath.Join(m.repoDir, fmt.Sprintf("%d.git", r.ID))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if _, err := m.git("", "init", "--quiet", "--bare", dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
src := m.gitea.base + "/" + r.FullName + ".git"
|
||||
args := append([]string{"-c", "http.extraHeader=" + m.gitea.gitAuth(), "fetch", "--quiet", "--prune", "--no-tags", src}, refspecs...)
|
||||
if _, err := m.git(dir, args...); err != nil {
|
||||
return fmt.Errorf("fetch from Gitea: %w", err)
|
||||
}
|
||||
// rad init and the project's default branch read HEAD.
|
||||
if _, err := m.git(dir, "symbolic-ref", "HEAD", "refs/heads/"+r.DefaultBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
digest, err := m.refsDigest(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cur.RID == "" {
|
||||
// A previous attempt may have created it and failed afterwards: rad init leaves the remote.
|
||||
if rid := m.remoteRID(dir); rid != "" {
|
||||
cur.RID = rid
|
||||
} else {
|
||||
rid, err := m.radInit(dir, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rad init: %w", err)
|
||||
}
|
||||
cur.RID = rid
|
||||
log.Printf("sync %s: created %s", r.FullName, rid)
|
||||
}
|
||||
cur.DefaultBranch = r.DefaultBranch
|
||||
m.save(*cur) // before anything else can fail: never init the same repository twice
|
||||
} else if m.remoteRID(dir) == "" {
|
||||
// The mirror was deleted (it is disposable): point a fresh one at the existing repository.
|
||||
if err := m.addRemote(dir, cur.RID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !cur.Crefs {
|
||||
if err := m.radCanonicalTags(cur.RID); err != nil {
|
||||
log.Printf("sync %s: canonical tags rule: %v (tags stay under the node's namespace)", r.FullName, err)
|
||||
} else {
|
||||
cur.Crefs = true
|
||||
}
|
||||
}
|
||||
|
||||
if digest == cur.Refs && cur.State == stSynced && cur.DefaultBranch == r.DefaultBranch {
|
||||
return nil
|
||||
}
|
||||
out, err := m.git(dir, append([]string{"push", "--prune", "--porcelain", "-o", "no-sync", "rad"}, refspecs...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push to Radicle: %w", err)
|
||||
}
|
||||
if changed := pushedRefs(out); len(changed) > 0 {
|
||||
log.Printf("sync %s → %s: %s", r.FullName, cur.RID, strings.Join(changed, ", "))
|
||||
}
|
||||
if cur.DefaultBranch != r.DefaultBranch {
|
||||
if err := m.radDefaultBranch(cur.RID, r.DefaultBranch); err != nil {
|
||||
return fmt.Errorf("default branch: %w", err)
|
||||
}
|
||||
cur.DefaultBranch = r.DefaultBranch
|
||||
}
|
||||
cur.Refs = digest
|
||||
m.announce(cur.RID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refsDigest summarises the mirror's branches and tags; equal digests mean nothing to push.
|
||||
func (m *mirror) refsDigest(dir string) (string, error) {
|
||||
out, err := m.git(dir, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(out))
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// pushedRefs reads `git push --porcelain` output and returns the refs that changed, for the log.
|
||||
// Porcelain lines are "<flag>\t<from>:<to>\t<summary>"; '=' is up to date.
|
||||
func pushedRefs(out string) []string {
|
||||
var changed []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 3 || len(f[0]) != 1 || f[0] == "=" {
|
||||
continue
|
||||
}
|
||||
to := f[1]
|
||||
if i := strings.LastIndexByte(to, ':'); i >= 0 {
|
||||
to = to[i+1:]
|
||||
}
|
||||
changed = append(changed, strings.TrimPrefix(strings.TrimPrefix(to, "refs/heads/"), "refs/")+" "+f[2])
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import "sync"
|
||||
|
||||
// queue holds the repositories waiting for a sync, each at most once, served by one worker: a
|
||||
// burst of webhooks for one repository costs one sync, and two syncs never race on a mirror or on
|
||||
// the node's storage.
|
||||
type queue struct {
|
||||
mu sync.Mutex
|
||||
order []int64
|
||||
pending map[int64]bool
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newQueue() *queue {
|
||||
return &queue{pending: map[int64]bool{}, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (q *queue) add(id int64) {
|
||||
q.mu.Lock()
|
||||
if !q.pending[id] {
|
||||
q.pending[id] = true
|
||||
q.order = append(q.order, id)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
select {
|
||||
case q.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// next pops the oldest repository, or reports false when the queue is empty.
|
||||
func (q *queue) next() (int64, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.order) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
id := q.order[0]
|
||||
q.order = q.order[1:]
|
||||
delete(q.pending, id)
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (q *queue) run(work func(id int64)) {
|
||||
for {
|
||||
id, ok := q.next()
|
||||
if !ok {
|
||||
<-q.wake
|
||||
continue
|
||||
}
|
||||
work(id)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:example
|
||||
var exampleFiles embed.FS
|
||||
|
||||
const tokenName = "ipcr-forge-bridge"
|
||||
|
||||
// setup is the install step, run after the stack is up and on every start (each action is
|
||||
// idempotent). It signs in once as the admin with the server's default app password, because
|
||||
// Gitea's token API accepts nothing else, and leaves:
|
||||
//
|
||||
// SECRETS_DIR/gitea-token public-only + read:repository: the daemon lists and clones public
|
||||
// repositories, and cannot see any other. Minted only when missing.
|
||||
// SECRETS_DIR/hook-secret the webhook's HMAC key. Generated only when missing.
|
||||
// a system webhook every repository's push/create/delete/repository events → HOOK_URL,
|
||||
// created or repaired (matched by URL), never duplicated.
|
||||
// EXAMPLE_REPO a public example repository with a workflow, created when absent.
|
||||
//
|
||||
// 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.
|
||||
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"))
|
||||
g.password = os.Getenv("GITEA_PASSWORD")
|
||||
if g.password == "" {
|
||||
return errors.New("GITEA_PASSWORD is required")
|
||||
}
|
||||
|
||||
// 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.
|
||||
var code int
|
||||
var err error
|
||||
for i := 0; i < 80; i++ {
|
||||
if code, err = g.call("GET", "/user", nil, nil); err == nil {
|
||||
break
|
||||
}
|
||||
if code == http.StatusUnauthorized && g.hasToken() {
|
||||
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
|
||||
return nil
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Gitea not ready after 240s: %w", err)
|
||||
}
|
||||
|
||||
if !g.hasToken() {
|
||||
g.call("DELETE", "/users/"+g.owner+"/tokens/"+tokenName, nil, nil)
|
||||
var t struct {
|
||||
SHA1 string `json:"sha1"`
|
||||
}
|
||||
if _, err := g.call("POST", "/users/"+g.owner+"/tokens", map[string]any{
|
||||
"name": tokenName, "scopes": []string{"public-only", "read:repository"},
|
||||
}, &t); err != nil {
|
||||
return fmt.Errorf("token: %w", err)
|
||||
}
|
||||
if err := writeSecret(g.tokenFile, t.SHA1); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("setup: token %s written", tokenName)
|
||||
}
|
||||
|
||||
secretFile := filepath.Join(secrets, "hook-secret")
|
||||
secret, _ := os.ReadFile(secretFile)
|
||||
if strings.TrimSpace(string(secret)) == "" {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
secret = []byte(hex.EncodeToString(b))
|
||||
if err := writeSecret(secretFile, string(secret)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ensureHook(g, env("HOOK_URL", "http://ipcr-forge:8081/hooks/gitea"), strings.TrimSpace(string(secret))); err != nil {
|
||||
return fmt.Errorf("webhook: %w", err)
|
||||
}
|
||||
|
||||
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
|
||||
// Not fatal: the forge works without its example.
|
||||
if err := ensureExample(g, name); err != nil {
|
||||
log.Printf("setup: example repository: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSecret(path, value string) error {
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ensureHook creates the system webhook, or rewrites the one already pointing at url (Gitea never
|
||||
// returns a hook's secret, so it is set every time rather than compared).
|
||||
func ensureHook(g *gitea, url, secret string) error {
|
||||
config := map[string]string{"url": url, "content_type": "json", "secret": secret, "is_system_webhook": "true"}
|
||||
events := []string{"push", "create", "delete", "repository"}
|
||||
var hooks []struct {
|
||||
ID int64 `json:"id"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if _, err := g.call("GET", "/admin/hooks?type=all&limit=50", nil, &hooks); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range hooks {
|
||||
if h.Config["url"] == url {
|
||||
_, err := g.call("PATCH", fmt.Sprintf("/admin/hooks/%d", h.ID), map[string]any{
|
||||
"config": config, "events": events, "active": true, "branch_filter": "*",
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := g.call("POST", "/admin/hooks", map[string]any{
|
||||
"type": "gitea", "config": config, "events": events, "active": true, "branch_filter": "*",
|
||||
}, nil)
|
||||
if err == nil {
|
||||
log.Printf("setup: system webhook → %s", url)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureExample creates <owner>/<name>, public, with the files under example/ in one commit. A
|
||||
// repository that already has content is left as it is: it may be the user's by now.
|
||||
func ensureExample(g *gitea, name string) error {
|
||||
var r repo
|
||||
code, err := g.call("GET", "/repos/"+g.owner+"/"+name, nil, &r)
|
||||
switch {
|
||||
case code == http.StatusNotFound:
|
||||
if _, err := g.call("POST", "/admin/users/"+g.owner+"/repos", map[string]any{
|
||||
"name": name, "private": false, "default_branch": "main",
|
||||
"description": "Example: a tag here builds an image, published on IPFS by IPCR and mirrored to Radicle.",
|
||||
}, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("setup: created %s/%s", g.owner, name)
|
||||
case err != nil:
|
||||
return err
|
||||
case !r.Empty:
|
||||
return nil
|
||||
}
|
||||
var files []map[string]string
|
||||
err = fs.WalkDir(exampleFiles, "example", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := exampleFiles.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, map[string]string{
|
||||
"operation": "create", "path": strings.TrimPrefix(p, "example/"),
|
||||
"content": base64.StdEncoding.EncodeToString(b),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = g.call("POST", "/repos/"+g.owner+"/"+name+"/contents", map[string]any{
|
||||
"branch": "main", "message": "Example: a Dockerfile and the workflow that publishes it", "files": files,
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Repository states, as shown on the page.
|
||||
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
|
||||
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
|
||||
)
|
||||
|
||||
// repoState is what the bridge remembers per Gitea repository, keyed by Gitea's numeric ID so a
|
||||
// rename or a transfer keeps its Radicle ID.
|
||||
type repoState struct {
|
||||
ID int64 `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
RID string `json:"rid,omitempty"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastSync time.Time `json:"last_sync,omitzero"`
|
||||
// A digest of the refs last pushed: a reconcile that finds the same refs pushes nothing.
|
||||
Refs string `json:"refs,omitempty"`
|
||||
// The canonical-tags rule is in the repository's identity (see rad.go).
|
||||
Crefs bool `json:"crefs,omitempty"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
repos map[int64]*repoState
|
||||
}
|
||||
|
||||
func loadState(path string) (*state, error) {
|
||||
s := &state{path: path, repos: map[int64]*repoState{}}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]*repoState
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range m {
|
||||
s.repos[r.ID] = r
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// get returns a copy of the repository's state (a zero one, with the ID set, when unknown).
|
||||
func (s *state) get(id int64) repoState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if r, ok := s.repos[id]; ok {
|
||||
return *r
|
||||
}
|
||||
return repoState{ID: id}
|
||||
}
|
||||
|
||||
func (s *state) known(id int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.repos[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// put stores r and writes the file (renamed into place, so a reader never sees half of it).
|
||||
func (s *state) put(r repoState) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.repos[r.ID] = &r
|
||||
m := make(map[string]*repoState, len(s.repos))
|
||||
for id, v := range s.repos {
|
||||
m[strconv.FormatInt(id, 10)] = v
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(s.path), "."+filepath.Base(s.path)+".new")
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func (s *state) ids() []int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids := make([]int64, 0, len(s.repos))
|
||||
for id := range s.repos {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
// list is the page's view: every repository, by name, without internal bookkeeping.
|
||||
func (s *state) list() []repoState {
|
||||
s.mu.Lock()
|
||||
out := make([]repoState, 0, len(s.repos))
|
||||
for _, r := range s.repos {
|
||||
v := *r
|
||||
v.Refs = ""
|
||||
out = append(out, v)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].FullName < out[j].FullName })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
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.Handler {
|
||||
mux := http.NewServeMux()
|
||||
page, _ := webFiles.ReadFile("web/index.html")
|
||||
icon, _ := webFiles.ReadFile("web/icon.png")
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(page)
|
||||
})
|
||||
mux.HandleFunc("GET /icon.png", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "max-age=3600")
|
||||
w.Write(icon)
|
||||
})
|
||||
mux.HandleFunc("GET /images.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := os.ReadFile(publishedFile)
|
||||
if err != nil {
|
||||
http.Error(w, "nothing published yet", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("GET /repos.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(st.list())
|
||||
})
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok\n"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,382 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IPCR Forge</title>
|
||||
<link rel="icon" href="icon.png">
|
||||
<!--
|
||||
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
|
||||
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<domain>,
|
||||
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
||||
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
|
||||
mirror state) and the Radicle API's node info.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
||||
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
||||
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
|
||||
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
|
||||
header img { width: 64px; height: 64px; }
|
||||
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
|
||||
h2 { font-size: 21px; margin: 40px 0 12px; }
|
||||
h3 { font-size: 16px; margin: 20px 0 6px; }
|
||||
p { margin: 8px 0; }
|
||||
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
|
||||
a { color: var(--accent); }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
||||
text-decoration: none; color: inherit; display: block; }
|
||||
a.card:hover { border-color: var(--accent); }
|
||||
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
|
||||
.card span { color: var(--muted); font-size: 14px; }
|
||||
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
|
||||
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 10px 12px; font-size: 14px; }
|
||||
.step b { display: block; font-size: 15px; }
|
||||
.step small { color: var(--muted); }
|
||||
.arrow { align-self: center; color: var(--muted); }
|
||||
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
|
||||
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
||||
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
|
||||
overflow-x: auto; line-height: 1.5; }
|
||||
pre code { background: none; padding: 0; }
|
||||
ol { padding-left: 22px; }
|
||||
li { margin: 6px 0; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 10px; overflow: hidden; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td code { word-break: break-all; }
|
||||
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
||||
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
|
||||
.muted { color: var(--muted); }
|
||||
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
||||
.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); }
|
||||
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<header>
|
||||
<img src="icon.png" alt="">
|
||||
<div>
|
||||
<h1>IPCR Forge</h1>
|
||||
<div class="muted" id="host"></div>
|
||||
</div>
|
||||
</header>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<h2 id="how">How it works</h2>
|
||||
<div class="pipe">
|
||||
<div class="step"><b>1 · Push</b><small><code>git push</code> a <code>v*</code> tag to Gitea, or create it in Gitea's web UI</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>2 · Build</b><small>Gitea Actions runs <code>.gitea/workflows/</code> and pushes the image to <code>localhost:5000</code></small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>3 · IPFS</b><small>IPCR imports each tag and publishes it under this forge's IPNS name</small></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>4 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
||||
</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>
|
||||
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>
|
||||
|
||||
<h2 id="example">Try it with the example</h2>
|
||||
<p>The install created <a id="link-hello" href="#"><code>gitea_admin/ipcr-hello</code></a>, a tiny web
|
||||
page with a Dockerfile and the workflow that publishes it.</p>
|
||||
<ol>
|
||||
<li>Open it in Gitea, then <b>Tags → New tag</b> (or <b>Releases → New release</b>): name
|
||||
<code>v1.0.0</code>, target <code>main</code>.</li>
|
||||
<li>Follow the build under the repository's <b>Actions</b> tab.</li>
|
||||
<li>About a minute after it finishes, the image appears below under
|
||||
<a href="#images">Published images</a>, and the tag on <a href="#repos">Radicle</a>.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="images">Published images</h2>
|
||||
<div id="images-body"><p class="muted">Loading…</p></div>
|
||||
|
||||
<h2 id="publish">Publish your own project</h2>
|
||||
<ol>
|
||||
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
|
||||
and push your code to it:
|
||||
<pre><code id="clone-cmd">git remote add origin https://gitea-<domain>/<owner>/<repo>.git
|
||||
git push -u origin main</code></pre>
|
||||
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
|
||||
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 two differences: the image goes to <code>localhost:5000</code>, this forge's
|
||||
staging registry (no login, no secret), and buildx's builder runs with <code>network=host</code>
|
||||
so it can reach that registry.
|
||||
<pre><code>name: image
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: localhost:5000/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}</code></pre></li>
|
||||
<li><b>Release:</b>
|
||||
<pre><code>git add .gitea && git commit -m "Build the image"
|
||||
git tag v1.0.0
|
||||
git push origin main v1.0.0</code></pre>
|
||||
The image is published as <code><owner>/<repo>:1.0.0</code> under this forge's name.</li>
|
||||
</ol>
|
||||
<p class="note">Who can publish: anyone who can push to a repository on this forge, since a build
|
||||
can push any image name to the staging registry. Images are public once on IPFS, even when they come from
|
||||
a private repository. Gitea holds a pull request from a fork until someone with write access approves
|
||||
its workflow run: read the workflow before you approve.</p>
|
||||
|
||||
<h2 id="use">Use an image</h2>
|
||||
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
||||
server and on any server running IPCR:</p>
|
||||
<pre><code>services:
|
||||
myapp:
|
||||
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0 # a tag: follows new builds of 1.0.0
|
||||
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
|
||||
<p>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0</code>.
|
||||
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/<cid></code> is the
|
||||
image's content address and can never change.</p>
|
||||
<p class="note">For a readable name, point a name you own at this forge's publisher name
|
||||
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
|
||||
<ul>
|
||||
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
|
||||
<code>ipns://<span class="publisher">k51…</span></code> (one transaction, once). Images are then
|
||||
<code>ipcr.localhost:4767/ipns/example.eth/<owner>/<repo>:1.0.0</code>.</li>
|
||||
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
|
||||
<code>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
|
||||
<code>ipcr.localhost:4767/ipns/example.com/<owner>/<repo>:1.0.0</code>.</li>
|
||||
</ul>
|
||||
<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
|
||||
(<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
|
||||
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p>
|
||||
|
||||
<h2 id="install">Pull from another server</h2>
|
||||
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
|
||||
from this server and from every other server that already pulled it.</p>
|
||||
<h3>On a Yundera server</h3>
|
||||
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
|
||||
<h3>On any Linux server with Docker 29+</h3>
|
||||
<pre><code>mkdir -p /DATA/AppData/ipcr && cd /DATA/AppData/ipcr
|
||||
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
|
||||
install -d -m 0700 -o 1000 -g 1000 kubo && install -d -o 1000 -g 1000 init.d
|
||||
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
|
||||
chmod 0755 init.d/01-ipcr-node.sh
|
||||
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' > .env
|
||||
docker compose up -d</code></pre>
|
||||
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
|
||||
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
|
||||
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
|
||||
|
||||
<h2 id="addresses">Addresses on this server</h2>
|
||||
<div class="scroll"><table>
|
||||
<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><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
|
||||
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
|
||||
</table></div>
|
||||
|
||||
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
var host = location.hostname;
|
||||
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
|
||||
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
|
||||
function setLink(id, prefix, text) {
|
||||
var a = document.getElementById(id);
|
||||
if (!a) return;
|
||||
a.href = url(prefix);
|
||||
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
||||
}
|
||||
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
|
||||
document.getElementById("host").textContent = base || host;
|
||||
setLink("link-gitea", "gitea");
|
||||
all("a.gitea-link", function (a) { a.href = url("gitea"); });
|
||||
all("a.radicle-link", function (a) { a.href = url("radicle"); });
|
||||
document.getElementById("link-hello").href = base ? url("gitea") + "/gitea_admin/ipcr-hello" : "#";
|
||||
if (base) {
|
||||
document.getElementById("clone-cmd").textContent =
|
||||
"git remote add origin " + url("gitea") + "/<owner>/<repo>.git\ngit push -u origin main";
|
||||
}
|
||||
setLink("t-gitea", "gitea", true);
|
||||
setLink("t-forge", "ipcr-forge", true);
|
||||
setLink("t-radicle", "radicle", true);
|
||||
setLink("t-radicle-api", "radicle-api", true);
|
||||
|
||||
function el(tag, text, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (text != null) e.textContent = text;
|
||||
if (cls) e.className = cls;
|
||||
return e;
|
||||
}
|
||||
function link(href, text) { var a = el("a", text); a.href = href; return a; }
|
||||
function copyButton(text) {
|
||||
var b = el("button", "Copy", "copy");
|
||||
b.type = "button";
|
||||
b.addEventListener("click", function () {
|
||||
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
||||
} else { window.prompt("Copy:", text); }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
|
||||
function cmpTags(a, b) {
|
||||
if (a === "latest") return -1;
|
||||
if (b === "latest") return 1;
|
||||
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
|
||||
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
var x = pa[i] || "", y = pb[i] || "";
|
||||
var nx = parseInt(x, 10), ny = parseInt(y, 10);
|
||||
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
|
||||
if (x !== y) return x < y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function table(headers) {
|
||||
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
|
||||
headers.forEach(function (h) { head.appendChild(el("th", h)); });
|
||||
t.appendChild(head);
|
||||
wrap.appendChild(t);
|
||||
return { wrap: wrap, t: t };
|
||||
}
|
||||
|
||||
// Published images: IPCR's published.json.
|
||||
var imagesBody = document.getElementById("images-body");
|
||||
fetch("images.json", { cache: "no-store" })
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||
.then(function (pub) {
|
||||
var reg = pub.registry || "ipcr.localhost:4767";
|
||||
if (pub.publisher) {
|
||||
document.getElementById("publisher-name").textContent = pub.publisher;
|
||||
all("span.publisher", function (e) { e.textContent = pub.publisher; });
|
||||
}
|
||||
var repos = Object.keys(pub.images || {}).sort();
|
||||
if (!repos.length) throw new Error("empty");
|
||||
imagesBody.textContent = "";
|
||||
repos.forEach(function (repo) {
|
||||
var r = pub.images[repo];
|
||||
imagesBody.appendChild(el("h3", repo));
|
||||
var tb = table(["Tag", "Compose line", ""]);
|
||||
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
||||
var info = r.tags[tag];
|
||||
var line = "image: " + reg + "/ipns/" + r.ipns + ":" + tag;
|
||||
var tr = el("tr");
|
||||
var td1 = el("td"); td1.appendChild(el("code", tag));
|
||||
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
|
||||
var td2 = el("td"); td2.appendChild(el("code", line));
|
||||
var pin = el("div", null, "muted");
|
||||
pin.appendChild(document.createTextNode("this build only: "));
|
||||
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
|
||||
td2.appendChild(pin);
|
||||
var td3 = el("td"); td3.appendChild(copyButton(line));
|
||||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||
tb.t.appendChild(tr);
|
||||
});
|
||||
imagesBody.appendChild(tb.wrap);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
imagesBody.innerHTML = "";
|
||||
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"));
|
||||
});
|
||||
|
||||
// Radicle mirrors: the bridge's state.
|
||||
var reposBody = document.getElementById("repos-body");
|
||||
fetch("repos.json", { cache: "no-store" })
|
||||
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
||||
.then(function (repos) {
|
||||
if (!repos || !repos.length) throw new Error("empty");
|
||||
reposBody.textContent = "";
|
||||
var tb = table(["Repository", "Radicle", "State"]);
|
||||
repos.forEach(function (r) {
|
||||
var tr = el("tr");
|
||||
var td1 = el("td");
|
||||
td1.appendChild(r.html_url ? link(r.html_url, r.full_name) : el("span", r.full_name));
|
||||
var td2 = el("td");
|
||||
if (r.rid) {
|
||||
td2.appendChild(base ? link(url("radicle") + "/nodes/radicle-api-" + base + "/" + r.rid, r.rid) : el("code", r.rid));
|
||||
var clone = el("div", null, "muted");
|
||||
clone.appendChild(el("code", "rad clone " + r.rid));
|
||||
td2.appendChild(clone);
|
||||
} else { td2.appendChild(el("span", "—", "muted")); }
|
||||
var td3 = el("td");
|
||||
td3.appendChild(el("span", r.state, "state " + r.state));
|
||||
if (r.last_sync) td3.appendChild(el("div", when(r.last_sync), "muted"));
|
||||
if (r.error) td3.appendChild(el("div", r.error, "muted"));
|
||||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||
tb.t.appendChild(tr);
|
||||
});
|
||||
reposBody.appendChild(tb.wrap);
|
||||
})
|
||||
.catch(function () {
|
||||
reposBody.innerHTML = "";
|
||||
reposBody.appendChild(el("p", "No public repository mirrored yet. Every public repository in Gitea appears here within a minute of its first push.", "muted"));
|
||||
});
|
||||
|
||||
// The node's ID. The Radicle API is public and CORS-enabled (the explorer calls it from the browser).
|
||||
if (base) {
|
||||
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
|
||||
.catch(function () {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user