# IPCR Forge — design notes This repository is the forge, an add-on to the IPCR engine ([Yundera/ipcr](https://github.com/Yundera/ipcr): the registry gateway, the IPFS import, the IPNS naming, the admin API). The engine knows nothing about Gitea, Radicle or this forge. The forge uses the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its [design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons). How the forge (store listing: [Apps/IPCR-Forge-AIO](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge-AIO), in the IPCR repository) goes from `git push` to `docker pull`, why it is shaped this way, and what was learned building it. The listings' own security argument is in [rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge-AIO/rationale.md). ## Goal A forge people actually want to use, whose releases do not depend on it staying online: - **Gitea is the source of truth.** Repositories, pull requests, issues, users and CI live there. Developers use plain git and a familiar web UI. - **Images go to IPFS** through IPCR, under one name for the whole forge, so any server running IPCR can pull them from any peer that holds them. - **Code goes to Radicle.** Every public repository is mirrored, one way, to the Radicle network, so it can be cloned without this server. ## History: three versions | Version | Source of truth | CI | Feedback | | --- | --- | --- | --- | | v1 (`afb1072`) | Radicle | Gitea Actions on a read-only Gitea fed by a Radicle → Gitea bridge | Radicle as the place to work was not user-friendly, and a read-only Gitea was confusing | | v2 (`734a33f`) | Radicle | Radicle CI broker + act on the node (`ci/`, removed since) | Not user-friendly enough: everything went through `rad` | | **v3** (this) | **Gitea** | **Gitea Actions** | — | v3 keeps what worked in both: v1's Gitea + Actions setup, and v2's staging registry inside the CI's own Docker daemon (no tokens, no registry credentials). The forge service is v1's glue reversed and rewritten in Go ([forge/](../forge/)). The objection that removed Gitea in v2 was "two places where state can originate". It does not apply here: state originates in Gitea only. Radicle receives a copy that nobody edits; patches or issues opened on the Radicle side are not read. ## Architecture ``` developer: git push origin main v1.2.3 (or: create a tag in Gitea's UI) │ ▼ gitea ─────────── system webhook (push/create/delete/repository) ──▶ ipcr-forge (forge service) │ · git fetch from Gitea │ Actions · rad init (first time) ▼ · git push rad (signed by the node) forge-runner (act_runner 0.6.1) ──unix socket──▶ forge-docker (dind) ▼ jobs on the daemon's host network → localhost:5000 = radicle-node ──▶ Radicle network forge-registry (registry:3, shares dind's network namespace) ▲ polled every minute over ci-internal ipcr-gateway (ipcrd): import → IPFS → publish under the forge's IPNS key │ ▼ anyone: docker pull ipcr.localhost:4767/ipns///:1.2.3 ``` The two legs do not depend on each other: a repository can be built without being mirrored (private repositories), and mirrored without being built (no workflow). ## One listing: all in one The forge ships as one listing, **IPCR-Forge-AIO**: Gitea, Radicle and IPCR bundled, with the forge's own runner (labels `ipcr` and `ubuntu-latest`/`-24.04`/`-22.04`). It cannot sit beside the store's Gitea, Radicle or IPCR apps (same container names, domains, ports). A split listing (`Apps/IPCR-Forge`, the forge on top of the store's Gitea app and, optionally, its Radicle app) existed until IPCR 1.7.1. It was retired: beside a stock Gitea, what is wanted is a registry, not a forge, and the IPCR app is now that registry, with its own push tokens, dashboard and public pulls, and no Gitea or Radicle knowledge ([push.md](https://github.com/Yundera/ipcr/blob/main/docs/push.md)). What it cost, for the record: the Gitea admin password at install, a webhook allowance in the Gitea listing, a read-write mount of the Radicle app's home, and `rad` versions kept in step across two listings. Its code paths (`RUNNER_DIR`, the missing-Gitea and missing-Radicle states) are still here, unused by the AIO listing, until a cleanup. - **Versions**: the forge image carries the `rad` CLI at the version the Radicle listing pins (`RADICLE_VERSION` in [forge/Dockerfile](../forge/Dockerfile)). Bump both together. ## The forge service [forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`. - **`setup`**, an install step (`post_up`, on every start, idempotent). It signs in once as `gitea_admin` with the default app password and does four things: - mints the daemon's token, `public-only` + `read:repository`; - generates the webhook secret; - creates or repairs one **system webhook** (`POST /api/v1/admin/hooks`, `is_system_webhook`), matched by URL so it is never duplicated; - creates the example `gitea_admin/ipcr-hello`. - **`serve`**: - `:8081` takes the webhook. It is internal, with no Caddy route. It checks the HMAC, queues the repository ID and answers 202 at once, because Gitea gives a delivery 5 seconds. - One worker, with a coalescing queue: a burst of deliveries is one sync. - A **reconcile** every 10 minutes catches what no event announces. In Gitea 1.27 that is renames and visibility changes, plus any lost delivery. - `:8080` serves the forge's page (`/`, `/images.json`, `/repos.json`). It is the place for future admin pages, such as Radicle settings. ### Mirror semantics | Gitea | Radicle | | --- | --- | | public repository, first sight | `rad init --public` from a bare mirror; this node is the only delegate; then a `xyz.radicle.crefs` rule `refs/tags/*` (threshold 1) so tags are canonical | | push, force push, branch delete | `git push --prune rad +refs/heads/*:… +refs/tags/*:…`, then `rad sync --announce` | | tag created (git or web UI) | same; becomes canonical `refs/tags/` | | tag deleted | removed from the node's namespace; **the canonical `refs/tags/` stays** (see findings) | | default branch changed | `rad id update … defaultBranch` | | rename / transfer | same RID (state is keyed by Gitea's repository ID) | | made private, deleted | **frozen**: no further sync. Nothing is retracted; it cannot be | | private, fork, pull mirror | never mirrored (forks and mirrors did not originate here) | | empty | waits for its first push | The token cannot see a private repository at all (`public-only`), so a bug cannot publish one. The code checks visibility again anyway. ## Trust - **Radicle signatures mean "this forge published it"**, not "this developer wrote it". Radicle is a distribution channel here, not the trust root. The `/rad//…` image-naming idea in [naming.md](https://github.com/Yundera/ipcr/blob/main/docs/naming.md) is weaker for it. - **Only allowed names are published.** IPCR publishes an image name only if it is on the allowlist the forge service keeps (`IMPORT_ALLOW_REQUIRED`). That list holds the public Gitea repositories, minus those switched off in the admin pages. Images built from private repositories are therefore not published. - **Each repository publishes its own name only.** The staging registry sits behind a gate (see [Staging gate](#staging-gate-and-push-credentials)). A job logs in with its repository's `IPCR_PUSH_TOKEN`, and may push `/` and nothing else. Gitea does not give secrets to workflows from forks, so a fork's pull request cannot publish. - **The root organisation's short names belong to its members.** Gitea teams decide who can push to `metadec/*`, so who can publish `metadec.eth/`. - **Not a sandbox.** Jobs run on a privileged Docker-in-Docker daemon. Anyone who can run a workflow here can break out of it to the host, and from there to everything, including the gate. The gate stops the ordinary way to publish someone else's name; it does not contain a hostile CI user. Registration is off, so every user was created by the admin. - **Fork pull requests** from users without write access wait for "Approve and run" in Gitea 1.27. - The page, the tips and `rationale.md` say all of this plainly. - **Who can read a push token:** anyone with write access to its repository (a workflow can print it). Reissue it in the admin pages when a repository changes hands. ## Admin `https://ipcr-forge-/admin`. It is served by the forge service and only lets in Gitea site administrators. **Sign-in.** It uses an OAuth2 app that the `setup` step registers, with one redirect URI per host. The flow is the authorization code with PKCE and the `read:user` scope. The forge service reads `is_admin` from `/api/v1/user`, drops the Gitea token, and sets its own HMAC-signed session (8 h; the key lives in `forge/state`). Writes require the `X-IPCR-Admin` header and a same-origin request. **Behind the page.** The page calls the forge service, and the forge service calls ipcrd's admin API ([`gateway/admin.go`](https://github.com/Yundera/ipcr/blob/main/gateway/admin.go) in the IPCR engine). That API listens on `:4769`, on the `ipcr-admin` network (internal, forge service and gateway only), and needs a Bearer token from `ipcr/state/admin-token`. | Section | What it does | | --- | --- | | Publisher name | The key and its `k51…`. You enter your ENS or DNSLink name; it is checked by resolving it through Kubo. While it points here, pull lines (page and admin) use it. The check runs again every 30 min. | | Health | Shows the IPNS record held by this node and the one the network serves (delegated routing): sequence, expiry, whether it is the current tree. "Republish" refreshes it. Also: peers, the addresses the network sees, and storage against `StorageMax`. | | Published images | For each tag: "Findable?" (is this node a listed provider), "Announce", "Make latest", "Unpublish". Unpublish removes the tag from the tree, unpins the objects no other image uses, and leaves a tombstone so the tag is not re-imported. Staging tags that are not allowed, or not imported yet, offer "Import now". | | Which repositories may publish | One toggle per public repository. These toggles make up IPCR's allowlist. | | Key backup | "Download" encrypts the publisher key with a passphrase. "Restore" makes a backup the publisher key: the current key is kept under `-replaced-