Federated via graft
0

Configure Feed

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

graft-source / TUTORIAL.md
15 kB

Tutorial#

This walks through mirroring one repository between a Forgejo instance and a Radicle node, from nothing. Replace the names below with your own:

Placeholder Stands for
git.example.org your Forgejo instance
seed.example.org your Radicle node's radicle-httpd address
alice the Forgejo account that owns the repo
my-project the repository

Prerequisites#

  • A Forgejo instance you can create repos and tokens on.
  • A machine with git, the rad CLI, and a running radicle-node — see Radicle's own seeder guide if you don't have one yet.
  • Go 1.23+, to build graft.

1. Build graft#

git clone https://git.example.org/alice/graft.git
cd graft
go build -o graft ./cmd/sync
sudo mv graft /usr/local/bin/graft

2. Create a Forgejo token#

graft needs a token scoped to write:repository and write:issue on the repo it syncs.

curl -X POST -H "Authorization: token $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"graft","scopes":["write:repository","write:issue"]}' \
  https://git.example.org/api/v1/users/alice/tokens

Save the returned token somewhere graft can read and nowhere else can:

mkdir -p /etc/graft
echo "$TOKEN" > /etc/graft/my-project.token
chmod 600 /etc/graft/my-project.token
chown graft:graft /etc/graft/my-project.token   # the user graft actually runs as, not root

Sharing a token safely#

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:

  • Scope it to write:repository + write:issue. Never admin or org-wide.
  • Never paste it into chat, email, or a ticket.
  • Prefer the token owner writing it directly into /etc/graft/<name>.token over their own SSH session — no third party involved.
  • 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.
  • Rotate it if its exposure is ever in doubt.

See Built-in token exchange — graft has a one-time secret-share page for this.

3. Create the repo on Forgejo#

curl -X POST -H "Authorization: token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-project","auto_init":true,"default_branch":"main"}' \
  https://git.example.org/api/v1/user/repos

auto_init gives it one commit, so there's something to mirror.

4. Create the repo on Radicle#

Radicle repos are created from an existing git checkout. Clone the one you just made and initialize it:

git clone https://git.example.org/alice/my-project.git
cd my-project
rad init --name my-project --default-branch main --public

This prints a Repository ID (RID), something like rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5. Write it down — the config needs it.

If you have other Radicle nodes that should also carry this repo, tell them to seed it:

rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5

5. Write the config#

# /etc/graft/config.yaml
sync_interval: 5m
state_db: /var/lib/graft/state.db

repos:
  - name: my-project
    forgejo:
      base_url: https://git.example.org
      owner: alice
      repo: my-project
      token_file: /etc/graft/my-project.token
    radicle:
      rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
      http_base_url: https://seed.example.org
      rad_home: /home/graft/.radicle
    sync:
      git: true
      issues: true
      patches: true

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.

6. Run it once#

mkdir -p /var/lib/graft
graft -config /etc/graft/config.yaml -once

No output means no errors. Check both sides:

curl -s https://seed.example.org/api/v1/repos/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5

If you open an issue on the Forgejo repo and run graft -once again, it shows up on Radicle within that one pass.

7. Run it as a service#

sudo cp systemd/graft.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now graft

It now mirrors both repos every sync_interval, in both directions, on its own.

Adding another repo from the browser#

Steps 2–5 above — create the Forgejo repo, mint a token, rad init, write the config entry — are also a form, once graft itself is already running: the + New repo button next to the dashboard heatmap. Same prerequisites, listed inline on the page (create the Forgejo repo, generate a scoped token, rad init, note the RID), then one submission instead of four manual steps. Useful for a second, unrelated project once the first is up; not a way to bootstrap the very first one, since it needs graft's own HTTP server already serving.

Like every self-service form, it has an admin password field that's optional — see Two ways to submit a self-service form for what filling it in, leaving it blank, or getting it wrong each do.

Adding a peer to an existing federation#

Adding 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.

From the browser, this is the + Add Forgejo peer button (/add-peer): pick the federation from a dropdown, and the Radicle side (RID, seed, explorer) is filled in for you, not asked for — the manual steps below exist to show what that button does, and to cover the case where the peer's admin doesn't have dashboard access. Same optional-password rule as above.

Placeholder Stands for
git2.example.org the second Forgejo instance
bob the account on it that owns the mirrored repo

A 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.

  1. 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.

    echo "$TOKEN2" > /etc/graft/my-project-git2.token
    chmod 600 /etc/graft/my-project-git2.token
    chown graft:graft /etc/graft/my-project-git2.token
    
  2. 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):

    repos:
      - name: my-project-git1
        series: my-project          # both pairs share this
        forgejo:
          base_url: https://git.example.org
          owner: alice
          repo: my-project
          token_file: /etc/graft/my-project.token
        radicle:
          rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
          http_base_url: https://seed.example.org
          rad_home: /home/graft/.radicle
        sync: { git: true, issues: true, patches: true }
    
      - name: my-project-git2
        series: my-project          # joins the same federation
        forgejo:
          base_url: https://git2.example.org
          owner: bob
          repo: my-project
          token_file: /etc/graft/my-project-git2.token
        radicle:
          rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5   # same RID — not a new one
          http_base_url: https://seed.example.org   # same seed — not a new node
          rad_home: /home/graft/.radicle             # always graft's own, never the peer's
          explorer_url: https://explorer.example.org
        sync: { git: true, issues: true, patches: true }
    

    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.

  3. 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.

  4. 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.

Adding a brand-new Radicle node to the mesh#

Reusing 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:

From the browser, this is the + Add Radicle peer button (/add-radicle-peer): pick the federation, enter the new node's ID and address, and it runs the same two commands below on graft's own node. Same optional-password rule as above.

rad node connect <new-node-id>@<new-node-address>:8776
rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5

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.

Comments#

graft mirrors comments on issues and patches, both directions, from four origins:

  • Forgejo → Radicle and Radicle → Forgejo: native comments, polled and cross-mirrored each pass. Dedup is by content hash (comment_seen table); the mirrored copy is visibly prefixed **via Forgejo:** / **via Radicle:** for readers, but the actual loop guard is an invisible marker (zero-width characters, state.MarkMirrored/IsMirroredComment) appended to the body — a real comment that happens to start with the same visible text is never mistaken for one of graft's own mirrors.
  • ActivityPub: a Mastodon reply to one of graft's posts becomes a comment, prefixed **via Fediverse, @user:** (same invisible marker).
  • 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.

Dashboard cells are colored by kind only (commit, issue, patch, comment) — never by origin. Origin shows up as a tooltip note only, on hover.

Troubleshooting#

"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.

"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.

"'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=....

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.

Self-service forms: submitting and reviewing#

The three buttons next to the federation heatmap — + Add Forgejo peer, + Add Radicle peer, + New repo — are covered in context above, right next to their manual equivalents. This section covers what's common to all three, plus the two footer links:

  • Share a secret, once (/share/new, footer) — the one-time-secret page below. Creating a share needs no password; claiming needs the passcode.
  • Pending requests (/admin/pending, footer) — review queue, see below.

Two ways to submit a self-service form#

Each of the three onboarding forms has one optional field: the admin password. What happens depends on it:

  • Left blank — the submission becomes a pending request. Nothing happens yet: no sync, no rad node connect, nothing touches any Forgejo or Radicle instance. Anyone can submit one without ever knowing the password — this is the path for someone outside your trust circle (a contributor like Milo) proposing themselves as a peer.
  • Filled in, correct — goes live immediately, exactly as graft always has: the peer/repo is active within one sync_interval (no restart), or for a Radicle peer, rad node connect + rad seed run right away. This is still the right tool for a class or workshop where you hand the password to a trusted group who should self-serve without a review step.
  • Filled in, wrong — rejected outright. It never silently falls back to a pending request — that would turn the form into a way to probe the password with no lockout consequence, defeating the rate limiter below.

Reviewing pending requests#

/admin/pending is the only place the password gates anything for the request path. It's a stateless review list, no login session: entering the password once renders every pending Forgejo peer, new repo, and Radicle peer request, each with its own Approve/Reject button that re-submits the same password. Approving a Forgejo peer/repo request just flips it active (same "next sync pass" activation as the instant path); approving a Radicle peer request runs rad node connect + rad seed at that moment. Rejecting a Forgejo peer/repo request also deletes the token file it already wrote to disk, so a rejected submission leaves nothing behind.

Both checks against it (the onboarding forms' optional field, and the pending-review page) use admin_password from config.yaml (default graft; graft logs a startup warning if left at that default), constant-time comparison, rate-limited: 5 wrong attempts per IP or 20 total, per 15 minutes. /share/new's claim page is a separate secret entirely — the 6-digit passcode, checked by AES-GCM decryption rather than a stored comparison (see Built-in token exchange below) — with its own, independent rate limiter.

Built-in token exchange#

Built, reachable from the dashboard:

  • 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.
  • QR code. /share/<id>/qr.png encodes the claim link only, not the passcode.