- forge-runner: 1G (act_runner was OOM-killed mid-job at 256M) - bridge: log rad's stdout on failure; page links Gitea from its own host (html_url is the internal address); description fallback without it - docs/forge.md: holyhorse verification and findings 11-14
384 lines
20 KiB
HTML
384 lines
20 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">
|
|
<!--
|
|
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");
|
|
// Not r.html_url: the bridge talks to Gitea's internal address, which is what Gitea puts there.
|
|
td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, 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>
|