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.
This commit is contained in:
Worph
2026-10-04 18:13:11 +00:00
parent a8a3037cba
commit f82543f518
7 changed files with 509 additions and 462 deletions
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Replaces the image's own /etc/s6/act_runner/run. Two waits, both of which the
# stock script gets wrong for this deployment.
#
# 1. The nested Docker daemon. Upstream waits for it through s6's readiness
# notification. On a cold start with a warm image store dockerd takes longer
# than s6-notifyoncheck's window, the notification never arrives, s6 marks
# the service permanently failed, and act_runner sits blocked in
# `s6-svwait -U` for two to four minutes before s6 gives up and lets it past
# anyway. The runner shows "offline" in the forge for that whole time and
# picks up no jobs. Asking the daemon directly is the same wait without the
# guesswork.
#
# 2. The registration token. It is minted by an init step that runs after the
# stack is up, so on a first install it does not exist yet when this
# container starts — and the stock entrypoint reads the token file exactly
# once, before its retry loop, so a token that arrives late is never seen.
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
while ! docker info >/dev/null 2>&1; do
sleep 1
done
while [ ! -s "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE:-/data/token}" ] && [ ! -s /data/.runner ]; do
echo "waiting for the runner registration token"
sleep 2
done
exec run.sh
@@ -1,40 +0,0 @@
# act_runner configuration. Only the settings that differ from upstream's
# defaults are written out — `act_runner generate-config` prints the full set.
log:
level: info
runner:
# The file the runner's own registration is kept in, relative to /data.
file: .runner
# One job at a time. A PCS shares its CPU with everything else installed on
# it, and an image build is the least polite thing on the box.
capacity: 1
timeout: 1h
fetch_timeout: 5s
fetch_interval: 2s
# Overrides the labels stored at registration (.runner). These are upstream
# act_runner defaults: Gitea's own job images, which carry node, bash, git
# and the docker CLI with buildx, so marketplace actions (actions/checkout,
# docker/build-push-action, ...) run as they do on GitHub. The registration
# labels pointed at node:22-bookworm, which has no docker CLI.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "docker:docker://docker:28.5-cli"
cache:
# The cache server binds an address inside the runner container, which the
# job containers — created by the Docker daemon nested inside that same
# container — cannot route to. Left on, every `actions/cache` step fails
# noisily instead of being skipped.
enabled: false
container:
# Jobs run on the runner's own nested daemon, on its default bridge: they
# reach the internet, and they reach Gitea the way anything outside would,
# through its public address.
network: bridge
privileged: false
force_pull: false
+46 -31
View File
@@ -8,8 +8,9 @@
<!--
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>, gitea-<domain>), so the nip.io / sslip.io aliases work too.
Live data: /images.json (IPCR's published.json) and the Radicle API's node info.
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 {
@@ -78,11 +79,12 @@
</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, Gitea Actions builds it, and IPCR serves the image from IPFS.</p>
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" id="link-gitea" href="#"><b>Gitea</b><span>Builds, logs and the staging registry. Sign in as <code>gitea_admin</code>.</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>
@@ -90,17 +92,22 @@ service. Code lives on Radicle, Gitea Actions builds it, and IPCR serves the ima
<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 · Bridge</b><small>repos with <code>.gitea/workflows/</code> are copied into Gitea</small></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>Gitea Actions builds the image into Gitea's registry</small></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 the runner downloads its build images. A repository
needs no configuration beyond committing a workflow.</p>
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>
@@ -124,10 +131,13 @@ rad init</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>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root and publishes it for every <code>v*</code> tag. The
<code>REGISTRY_TOKEN</code> secret is already set for every repository. Commit it
(<code>git add .gitea &amp;&amp; git commit -m "Build the image"</code>).
<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*']
@@ -136,32 +146,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Registry address
id: registry
run: echo "host=&#36;{GITHUB_SERVER_URL#https://}" &gt;&gt; "&#36;GITHUB_OUTPUT"
- uses: docker/metadata-action@v5
id: meta
with:
images: &#36;{{ steps.registry.outputs.host }}/&#36;{{ github.repository }}
images: &#36;{{ vars.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: &#36;{{ steps.registry.outputs.host }}
username: &#36;{{ github.repository_owner }}
password: &#36;{{ secrets.REGISTRY_TOKEN }}
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 v1.0.0
<pre><code>git tag -a v1.0.0 -m v1.0.0
git push rad main v1.0.0</code></pre>
Follow the build in Gitea under <code>gitea_admin/&lt;repo&gt;</code> → Actions. A minute after
it finishes, the tag appears under <a href="#images">Published images</a>.</li>
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>
@@ -173,9 +178,17 @@ server and on any server running IPCR:</p>
# 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, add a DNS TXT record <code>_dnslink.example.com</code> =
<code>dnslink=/ipns/k51…</code> to a domain you own. The image is then
<code>ipcr.localhost:4767/ipns/example.com:1.0.0</code>.</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,
@@ -199,12 +212,12 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<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 id="t-gitea" href="#"></a></td><td>Gitea: builds, logs, staging registry</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 · Gitea · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
<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>
@@ -222,10 +235,8 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
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("link-gitea", "gitea");
setLink("t-radicle", "radicle", true);
setLink("t-radicle-api", "radicle-api", true);
setLink("t-gitea", "gitea", true);
function el(tag, text, cls) {
var e = document.createElement(tag);
@@ -262,12 +273,16 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
.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.replace(/^gitea_admin\//, "")));
body.appendChild(el("h3", repo));
var wrap = el("div", null, "scroll");
var t = el("table");
var head = el("tr");
+11 -1
View File
@@ -1,4 +1,5 @@
# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json.
# IPCR Forge landing page. Static, plus IPCR's published.json as /images.json and the CI broker's
# report pages (and the adapter's run logs) under /ci/.
# No nginx variables on purpose: this file is also safe to render as a Maison template.
server {
listen 8080;
@@ -12,6 +13,15 @@ server {
add_header Cache-Control "no-store";
}
# cib rewrites these pages as runs progress; logs are plain text.
location /ci/ {
alias /srv/ci/;
index index.html;
types { text/html html; text/css css; application/json json; text/plain log; }
charset utf-8;
add_header Cache-Control "no-cache";
}
# Only the page and its icon; nothing else under the state directory is reachable.
location / {
add_header Cache-Control "no-cache";