Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

1# Tutorial 2 3This walks through mirroring one repository between a Forgejo instance and a Radicle node, from nothing. Replace the names below with your own: 4 5| Placeholder | Stands for | 6|---|---| 7| `git.example.org` | your Forgejo instance | 8| `seed.example.org` | your Radicle node's `radicle-httpd` address | 9| `alice` | the Forgejo account that owns the repo | 10| `my-project` | the repository | 11 12## Prerequisites 13 14- A Forgejo instance you can create repos and tokens on. 15- A machine with `git`, the `rad` CLI, and a running `radicle-node` — see [Radicle's own seeder guide](https://radicle.dev/guides/seeder) if you don't have one yet. 16- Go 1.23+, to build `graft`. 17 18## 1. Build graft 19 20```sh 21git clone https://git.example.org/alice/graft.git 22cd graft 23go build -o graft ./cmd/sync 24sudo mv graft /usr/local/bin/graft 25``` 26 27## 2. Create a Forgejo token 28 29`graft` needs a token scoped to `write:repository` and `write:issue` on the repo it syncs. 30 31```sh 32curl -X POST -H "Authorization: token $ADMIN_TOKEN" \ 33 -H "Content-Type: application/json" \ 34 -d '{"name":"graft","scopes":["write:repository","write:issue"]}' \ 35 https://git.example.org/api/v1/users/alice/tokens 36``` 37 38Save the returned token somewhere `graft` can read and nowhere else can: 39 40```sh 41mkdir -p /etc/graft 42echo "$TOKEN" > /etc/graft/my-project.token 43chmod 600 /etc/graft/my-project.token 44chown graft:graft /etc/graft/my-project.token # the user graft actually runs as, not root 45``` 46 47### Sharing a token safely 48 49`config.yaml` never holds the token itself, only `token_file`. `graft` requires `token_file` and rejects an inline value. Getting the token into that file is on you: 50 51- Scope it to `write:repository` + `write:issue`. Never admin or org-wide. 52- Never paste it into chat, email, or a ticket. 53- Prefer the token owner writing it directly into `/etc/graft/<name>.token` over their own SSH session — no third party involved. 54- If a third party must relay it, use a one-time-secret tool (Bitwarden Send, 1Password Psst, self-hosted `onetimesecret.com`), not a plain link. 55- Rotate it if its exposure is ever in doubt. 56 57See [Built-in token exchange](#built-in-token-exchange) — `graft` has a one-time secret-share page for this. 58 59## 3. Create the repo on Forgejo 60 61```sh 62curl -X POST -H "Authorization: token $TOKEN" \ 63 -H "Content-Type: application/json" \ 64 -d '{"name":"my-project","auto_init":true,"default_branch":"main"}' \ 65 https://git.example.org/api/v1/user/repos 66``` 67 68`auto_init` gives it one commit, so there's something to mirror. 69 70## 4. Create the repo on Radicle 71 72Radicle repos are created from an existing git checkout. Clone the one you just made and initialize it: 73 74```sh 75git clone https://git.example.org/alice/my-project.git 76cd my-project 77rad init --name my-project --default-branch main --public 78``` 79 80This prints a Repository ID (RID), something like `rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5`. Write it down — the config needs it. 81 82If you have other Radicle nodes that should also carry this repo, tell them to seed it: 83 84```sh 85rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 86``` 87 88## 5. Write the config 89 90```yaml 91# /etc/graft/config.yaml 92sync_interval: 5m 93state_db: /var/lib/graft/state.db 94 95repos: 96 - name: my-project 97 forgejo: 98 base_url: https://git.example.org 99 owner: alice 100 repo: my-project 101 token_file: /etc/graft/my-project.token 102 radicle: 103 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 104 http_base_url: https://seed.example.org 105 rad_home: /home/graft/.radicle 106 sync: 107 git: true 108 issues: true 109 patches: true 110``` 111 112`rad_home` is the `RAD_HOME` of the identity `graft` pushes as — usually a dedicated one, created with `rad auth` under whatever user runs `graft`. 113 114## 6. Run it once 115 116```sh 117mkdir -p /var/lib/graft 118graft -config /etc/graft/config.yaml -once 119``` 120 121No output means no errors. Check both sides: 122 123```sh 124curl -s https://seed.example.org/api/v1/repos/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 125``` 126 127If you open an issue on the Forgejo repo and run `graft -once` again, it shows up on Radicle within that one pass. 128 129## 7. Run it as a service 130 131```sh 132sudo cp systemd/graft.service /etc/systemd/system/ 133sudo systemctl daemon-reload 134sudo systemctl enable --now graft 135``` 136 137It now mirrors both repos every `sync_interval`, in both directions, on its own. 138 139## Adding a peer to an existing federation 140 141Adding a second Forgejo instance to a repo already mirrored between one Forgejo and one Radicle node needs no new Radicle node and no change to the pairs already running. 142 143| Placeholder | Stands for | 144|---|---| 145| `git2.example.org` | the second Forgejo instance | 146| `bob` | the account on it that owns the mirrored repo | 147 148A Radicle RID replicates to every node that seeds it, independent of `graft`. Pointing a second pair's `radicle.http_base_url` at the same seed already in use doesn't create a new relationship — that seed already has the content. No Radicle-side step for this case. 149 1501. On `git2.example.org`, create the repo **without** `auto_init` (`auto_init: false` or omitted) and a token scoped `write:repository` + `write:issue`. `auto_init: true` gives the repo an unrelated first commit; `graft` sees that as a real divergence against the federation's existing history and refuses to push, logging "forgejo and rad diverged since last sync" (see Troubleshooting). An empty repo has no history to diverge from. 151 152 ```sh 153 echo "$TOKEN2" > /etc/graft/my-project-git2.token 154 chmod 600 /etc/graft/my-project-git2.token 155 chown graft:graft /etc/graft/my-project-git2.token 156 ``` 157 1582. Add a second entry to `config.yaml`, under the same `series` as the first pair (defaults to the pair's own `name` if you never set one — give the *first* pair an explicit `series` too if you didn't already, so both land on the same row): 159 160 ```yaml 161 repos: 162 - name: my-project-git1 163 series: my-project # both pairs share this 164 forgejo: 165 base_url: https://git.example.org 166 owner: alice 167 repo: my-project 168 token_file: /etc/graft/my-project.token 169 radicle: 170 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 171 http_base_url: https://seed.example.org 172 rad_home: /home/graft/.radicle 173 sync: { git: true, issues: true, patches: true } 174 175 - name: my-project-git2 176 series: my-project # joins the same federation 177 forgejo: 178 base_url: https://git2.example.org 179 owner: bob 180 repo: my-project 181 token_file: /etc/graft/my-project-git2.token 182 radicle: 183 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 # same RID — not a new one 184 http_base_url: https://seed.example.org # same seed — not a new node 185 rad_home: /home/graft/.radicle # always graft's own, never the peer's 186 explorer_url: https://explorer.example.org 187 sync: { git: true, issues: true, patches: true } 188 ``` 189 190 `rad_home` never changes between pairs — it's `graft`'s own local Radicle identity, the same one it pushes as for every pair it runs. Only the `forgejo` block is specific to the new peer. 191 1923. Restart: `sudo systemctl restart graft`. `config.Load` validates every field on startup — a typo surfaces immediately in the logs, not as a silent no-op. 193 1944. Confirm: the dashboard shows `git2.example.org` as a second row under `my-project`. A side with content but no direct push logged by `graft` shows colored cells instead of a normal commit/issue/patch cell — "source" (content originated here) or "replicated" (arrived via Radicle gossip or a Forgejo Authorized Integration, not a `graft`-logged push). Hover for date, origin, link. 195 196## Adding a brand-new Radicle node to the mesh 197 198Reusing an existing seed needs nothing extra. A node nobody in the mesh has talked to needs two manual steps — `graft`'s own Radicle node doesn't discover new peers on its own: 199 200```sh 201rad node connect <new-node-id>@<new-node-address>:8776 202rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 203``` 204 205`rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows, but a never-connected node needs one explicit introduction. `rad seed` is separate: default seeding policy replicates nothing not explicitly told to. Skip either step and the node stays unreachable or empty. 206 207## Comments 208 209`graft` mirrors comments on issues and patches, both directions, from four origins: 210 211- **Forgejo → Radicle** and **Radicle → Forgejo**: native comments, polled and cross-mirrored each pass. Dedup is by content hash (`comment_seen` table) plus a `**via Forgejo:**` / `**via Radicle:**` prefix on the mirrored copy, so a mirrored comment is never mirrored back. 212- **ActivityPub**: a Mastodon reply to one of graft's posts becomes a comment, prefixed `**via Fediverse, @user:**`. 213- **AT Proto**: a Bluesky reply, same mechanism, prefixed `**via Bluesky, @handle:**`. Not verified against a live account — built from AT Proto's published lexicon (`app.bsky.notification.listNotifications`), but no Bluesky account existed to test against when this was written. 214 215Dashboard cells are colored by kind only (commit, issue, patch, comment) — never by origin. Origin shows up as a tooltip note only, on hover. 216 217## Troubleshooting 218 219**"token does not have at least one of required scope(s)"** — the Forgejo token is missing `write:issue` or `write:repository`. Regenerate it with both. 220 221**"forgejo and rad diverged since last sync"** — both sides got commits since the last pass that aren't ancestors of each other. `graft` won't guess which one wins; merge them by hand on either side and it'll pick up cleanly next pass. 222 223**"'remote-rad' is not a git command"** — `git-remote-rad` (installed alongside `rad`) isn't on `PATH` for the user running `graft`. Add its directory to the systemd unit's `Environment=PATH=...`. 224 225**A patch/PR never shows up on the other side** — check that the corresponding repo isn't still empty on the destination. The first git sync has to land before patches/issues referencing that content can be mirrored. 226 227## Self-service dashboard actions 228 229Two buttons next to the federation heatmap, plus a footer link, do the above through a browser: 230 231- **`+ Add peer`** (`/add-peer`) — join an existing series. Radicle side (RID, seed, explorer) is filled in from the chosen series, not asked for. Repo must exist on the peer, empty (no auto-init — see above). Submit Forgejo details, token, admin password. Live within one `sync_interval`. 232- **`+ New repo`** (`/new-repo`) — prerequisites listed inline (create the Forgejo repo, generate a scoped token, `rad init`, note the RID), then a form. 233- **`Share a secret, once`** (`/share/new`, footer) — the one-time-secret page below. Creating a share needs no password; claiming needs the passcode. 234 235Both onboarding forms check `admin_password` from `config.yaml` (default `graft`; `graft` logs a startup warning if unchanged). Constant-time comparison, rate-limited: 5 wrong attempts per IP or 20 total, per 15 minutes. 236 237## Built-in token exchange 238 239**Built**, reachable from the dashboard: 240 241- **One-time secret share.** `POST /share/new` encrypts the input with AES-256-GCM, keyed from a passcode and the share's own random id together. Only the id's hash is stored — a database dump alone can't derive the key. `GET/POST /share/<id>` claims it: right passcode reveals it once and deletes the record; 5 wrong passcodes deletes it too; 15 minutes unclaimed and it's gone. 242- **QR code.** `/share/<id>/qr.png` encodes the claim link only, not the passcode. 243 244**Not built:** 245 246- **Wormhole-style PAKE exchange** (the protocol behind [`magic-wormhole`](https://github.com/magic-wormhole/magic-wormhole)): both sides type the same short code into a CLI; the key exchange derives a shared secret without the code or the token crossing the wire in the clear. `graft`'s server never sees the plaintext token. Most to build — a relay endpoint for the handshake, shell access on both sides. Skipped: the share page above covers the same case at a fraction of the cost. 247 248- **Skip the handoff entirely.** If Forgejo exposes a delegated token-creation flow (peer admin clicks a link, logs into their own Forgejo, a token scoped to exactly `write:repository` + `write:issue` on exactly that repo is minted and handed to `graft` server-to-server), no plaintext secret touches a clipboard, a chat window, or a QR code. Depends on Forgejo's OAuth2/application-token APIs supporting delegated, scope-limited minting driven by a third party — unverified against a current Forgejo instance.