Files
ipcr-forge/Apps/IPCR-Forge/seed/site/index.html
T
Worph f82543f518 IPCR Forge v2: Radicle-only with rad-actions
Snapshot of the Radicle-only forge (cib + act, DinD staging registry)
before the Gitea-centric v3 POC replaces it. Also carries the gateway's
IMPORT_PUBLISHER mode, multi-segment IPNS paths and IPCR 1.2.0.
2026-10-04 18:13:11 +00:00

335 lines
17 KiB
HTML

<!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">
<!--
Plain file, deliberately not a Maison .tmpl: it contains no dollar-brace sequence, and every
address is derived in the browser from this page's own hostname (ipcr-forge-<domain> →
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
Live data: /images.json (IPCR's published.json), /ci/ (the CI broker's report pages) 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;
}
@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;
}
}
* { 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; }
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, without a central
service. Code lives on Radicle, this node builds it with GitHub-style workflows, and IPCR serves
the image from IPFS.</p>
<div class="cards">
<a class="card" id="link-radicle" href="#"><b>Radicle</b><span>Browse repositories, issues and patches. Public.</span></a>
<a class="card" href="ci/"><b>CI runs</b><span>Every build this node ran, with its log and result. Public.</span></a>
<a class="card" href="#images"><b>Published images</b><span>What this server serves from IPFS, with the line to put in a compose file.</span></a>
</div>
<h2 id="how">How it works</h2>
<div class="pipe">
<div class="step"><b>1 · Push</b><small><code>git push rad</code> a <code>v*</code> tag to the Radicle network</small></div>
<div class="arrow">→</div>
<div class="step"><b>2 · Trigger</b><small>this node receives the tag and the Radicle CI broker picks it up</small></div>
<div class="arrow">→</div>
<div class="step"><b>3 · Build</b><small><code>act</code> runs the repo's <code>.github/workflows/</code> and pushes the image</small></div>
<div class="arrow">→</div>
<div class="step"><b>4 · IPFS</b><small>IPCR imports each tag and publishes it under an IPNS name</small></div>
<div class="arrow">→</div>
<div class="step"><b>5 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
</div>
<p>Steps 2 to 4 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. A repository needs no
configuration beyond committing a workflow. There is no second copy of it anywhere: the build
reads the commit straight from this node. Builds of the default branch and of patches also
write their result back into the repository, signed by the node, as a Radicle job.</p>
<p class="note">Only changes signed by one of the repository's delegates are built: release tags,
the default branch and their patches. A build can publish an image, so it gets the trust of a
release.</p>
<h2 id="images">Published images</h2>
<div id="images-body"><p class="muted">Loading…</p></div>
<h2 id="publish">Publish a project</h2>
<ol>
<li><b>Install Radicle</b> on your machine and create an identity: see
<a href="https://radicle.xyz/" target="_blank" rel="noopener">radicle.xyz</a>, then <code>rad auth</code>.
Start your own node, which carries your pushes: <code>rad node start</code>.</li>
<li><b>Connect to this server's node</b> so your pushes reach it:
<pre><code id="connect-cmd">rad node connect &lt;node-id&gt;@&lt;this server&gt;:8776</code></pre></li>
<li><b>Publish the repository.</b> It has to be a Git repository with at least one commit:
<pre><code>git init -b main
git add -A
git commit -m "First commit"
rad init</code></pre>
<code>rad init</code> prints the repository ID (<code>rad:z…</code>).</li>
<li><b>Let this node seed it.</b> The node only keeps repositories it is told to (seeding policy
<i>block</i>). In this server's Terminal app or over SSH:
<pre><code>docker exec radicle-node rad seed rad:z… --scope all</code></pre>
It ends with <code>✗ Target not met: could not fetch from […]</code>. That is about the public
Radicle seeds, not this server, and can be ignored. The repository is in place once it shows
in the <a class="radicle-link" href="#">Radicle explorer</a>.</li>
<li><b>Add a workflow</b> at <code>.github/workflows/build.yml</code>, as on GitHub. This one
builds the <code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag.
<code>vars.IMAGE</code> is set for every repository (this node's staging registry plus the
repository's name); there is no secret to configure. Two lines differ from a GitHub-hosted
workflow: <code>context: .</code> (builds the checkout, not a GitHub URL) and buildx's
<code>network=host</code> (so its builder can reach the staging registry). Commit it
(<code>git add .github &amp;&amp; git commit -m "Build the image"</code>).
<pre><code>on:
push:
tags: ['v*']
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ vars.IMAGE }}
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: &#36;{{ steps.meta.outputs.tags }}
labels: &#36;{{ steps.meta.outputs.labels }}</code></pre></li>
<li><b>Release:</b>
<pre><code>git tag -a v1.0.0 -m v1.0.0
git push rad main v1.0.0</code></pre>
Follow the build under <a href="ci/">CI runs</a>. A minute after it finishes, the tag appears
under <a href="#images">Published images</a>.</li>
</ol>
<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/k51qzi5uqu5d…: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><code>/ipns/&lt;name&gt;:&lt;tag&gt;</code> is a name the publisher can move to a new build.
<code>/ipfs/&lt;cid&gt;</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/&lt;repo&gt;: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/&lt;repo&gt;: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="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 &amp;&amp; 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 &amp;&amp; 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' &gt; .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-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><a href="ci/">ipcr-forge-…/ci/</a></td><td>CI runs and their logs (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 · Radicle · Radicle CI broker · act · 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>";
}
document.getElementById("host").textContent = base || host;
setLink("link-radicle", "radicle");
Array.prototype.forEach.call(document.querySelectorAll("a.radicle-link"), function (a) { a.href = url("radicle"); });
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 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 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;
}
var body = 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;
Array.prototype.forEach.call(document.querySelectorAll("span.publisher"), function (e) { e.textContent = pub.publisher; });
}
var repos = Object.keys(pub.images || {}).sort();
if (!repos.length) throw new Error("empty");
body.textContent = "";
repos.forEach(function (repo) {
var r = pub.images[repo];
body.appendChild(el("h3", repo));
var wrap = el("div", null, "scroll");
var t = el("table");
var head = el("tr");
["Tag", "Compose line", ""].forEach(function (h) { head.appendChild(el("th", h)); });
t.appendChild(head);
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", info.at.replace("T", " ").replace("Z", " UTC"), "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);
t.appendChild(tr);
});
wrap.appendChild(t);
body.appendChild(wrap);
});
})
.catch(function () {
body.innerHTML = "";
body.appendChild(el("p", "Nothing published yet. Push a v* tag from a repository with a workflow (see below); its image appears here a few minutes later.", "muted"));
});
// The node's ID and dialable address, for `rad node connect`. The Radicle API is public and
// CORS-enabled (the explorer calls it from the browser). The address must be the node's own
// externalAddresses entry (radicle-<ip>.sslip.io:8776): the *-<domain> names are behind an
// HTTP proxy that does not carry port 8776.
if (base) {
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (node) {
var addrs = (node.config && node.config.externalAddresses) || [];
if (node.id && addrs.length) {
document.getElementById("connect-cmd").textContent =
"rad node connect " + node.id + "@" + addrs[0];
}
})
.catch(function () {});
}
})();
</script>
</body>
</html>