ci: leave the downloads page to the DorkNet site

The server now offers the published builds on its own site (Downloads page,
Latest build on the Quest page), reading the bucket on the internal network.
Drop the standalone index.html and DORKNET_SITE_URL; the bucket needs no
public route or access app of its own.
This commit is contained in:
Alexa
2026-10-25 10:55:28 +02:00
parent 6e5b011059
commit ec88a39750
5 changed files with 48 additions and 317 deletions
-243
View File
@@ -1,243 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>DorkNet Downloads</title>
<link rel="icon" href="dorknet.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!--
The DorkNet downloads page. Published by ci/downloads/publish.py next to
manifest.json, which lists the files; this page only renders it. Colours
follow DorkNet.Server/site (tailwind.config.js).
-->
<style>
:root {
--bg: #0a0b0e; --surface: #131418; --surface-2: #1b1d22; --border: #23252b; --border-2: #34363d;
--text: #ececef; --muted: #a9abb4; --faint: #7e818d;
--brand: #0b95c7; --brand-hi: #22cfee; --brand-soft: rgba(11, 149, 199, .14); --on-brand: #fff;
--ok: #3ecf8e; --danger: #ef5b6b;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f7f7f8; --surface: #ffffff; --surface-2: #f7f7f8; --border: #ececef; --border-2: #d3d4d9;
--text: #131418; --muted: #43464f; --faint: #5d606b;
--brand: #087aa8; --brand-hi: #0b95c7; --brand-soft: rgba(11, 149, 199, .10);
--ok: #1f9d68; --danger: #c93a4b;
color-scheme: light;
}
}
:root[data-theme="light"] {
--bg: #f7f7f8; --surface: #ffffff; --surface-2: #f7f7f8; --border: #ececef; --border-2: #d3d4d9;
--text: #131418; --muted: #43464f; --faint: #5d606b;
--brand: #087aa8; --brand-hi: #0b95c7; --brand-soft: rgba(11, 149, 199, .10);
--ok: #1f9d68; --danger: #c93a4b;
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--brand-hi); }
code, .mono { font-family: "JetBrains Mono", SFMono-Regular, Menlo, monospace; font-size: .86em; }
.wrap { max-width: 980px; margin: 0 auto; padding: 0 16px; }
header { border-bottom: 1px solid var(--border); background: var(--surface); }
header .wrap { display: flex; align-items: center; gap: 12px; height: 60px; }
header img { width: 30px; height: 30px; }
header .name { font-weight: 700; letter-spacing: -.01em; }
header .name span { color: var(--faint); font-weight: 500; }
header .spacer { flex: 1; }
header a.signout { color: var(--muted); font-size: 13px; text-decoration: none; }
header a.signout:hover { color: var(--text); }
.intro { padding: 36px 0 8px; }
h1 { font-size: 28px; line-height: 1.2; letter-spacing: -.02em; margin: 0 0 6px; }
.lede { color: var(--muted); margin: 0; max-width: 60ch; }
.release { padding: 20px 0 8px; }
.release-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 14px; }
.release-head h2 { font-size: 20px; margin: 0; letter-spacing: -.01em; }
.release-head .sub { color: var(--faint); font-size: 13px; }
.platform { margin: 0 0 22px; }
.platform h3 {
display: flex; align-items: center; gap: 8px; font-size: 13px; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); margin: 0 0 10px; font-weight: 600;
}
.platform h3 svg { width: 16px; height: 16px; }
.platform .hint { color: var(--faint); font-size: 13px; margin: -4px 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 12px; }
.card {
display: flex; flex-direction: column; gap: 10px; padding: 18px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
}
.card.primary { border-color: color-mix(in srgb, var(--brand) 55%, var(--border)); box-shadow: 0 0 0 3px var(--brand-soft); }
.card .top { display: flex; align-items: center; gap: 8px; }
.card h4 { margin: 0; font-size: 16px; }
.tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--brand-soft); color: var(--brand-hi); }
.card p { margin: 0; color: var(--muted); font-size: 14px; }
.card .file { color: var(--faint); word-break: break-all; }
.meta { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--faint); font-size: 12.5px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
.btn {
display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border-radius: 8px;
font: 600 14px/1 inherit; text-decoration: none; cursor: pointer; border: 1px solid transparent;
}
.btn svg { width: 16px; height: 16px; }
.btn-primary { background: var(--brand); color: var(--on-brand); }
.btn-primary:hover { background: var(--brand-hi); }
.btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.btn-secondary:hover { border-color: var(--faint); }
.btn:focus-visible { outline: 2px solid var(--brand-hi); outline-offset: 2px; }
.hash {
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px; border-radius: 6px;
background: transparent; border: 1px dashed var(--border-2); color: var(--faint); cursor: pointer; font: inherit; font-size: 12px;
}
.hash:hover { color: var(--text); }
.hash.copied { color: var(--ok); border-color: var(--ok); }
.note { color: var(--faint); font-size: 13px; padding: 8px 0 40px; border-top: 1px solid var(--border); margin-top: 12px; }
.note p { margin: 12px 0 0; }
.state { padding: 48px 0; color: var(--muted); }
.state.error { color: var(--danger); }
</style>
</head>
<body>
<header>
<div class="wrap">
<img src="dorknet.svg" alt="">
<div class="name">DorkNet <span>Downloads</span></div>
<div class="spacer"></div>
<a class="signout" href="/cdn-cgi/access/logout">Sign out</a>
</div>
</header>
<main class="wrap">
<section class="intro">
<h1>Get Rec Room for DorkNet</h1>
<p class="lede">Pick your platform. Every file is built from the DorkNet source by our build server; the
SHA-256 next to each one lets you check your download.</p>
</section>
<div id="releases"><p class="state">Loading builds…</p></div>
<section class="note">
<p>These builds contain Rec Room's own game files. They're for the DorkNet community only: please don't
re-share them.</p>
</section>
</main>
<script>
(() => {
const ICONS = {
pc: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
quest: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-3l-2-2.5h-2L9 17H6a3 3 0 0 1-3-3z"/></svg>',
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Turn on developer mode for your headset and allow USB debugging when asked.' },
];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const size = (b) => b >= 2 ** 30 ? `${(b / 2 ** 30).toFixed(2)} GB`
: b >= 2 ** 20 ? `${(b / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 2 ** 10))} KB`;
const date = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
// What each file is for, in players' terms. Keyed by the file ids publish.py writes.
function describe(f, rel, site) {
const questPage = site ? `<a href="${esc(site)}/quest" target="_blank" rel="noopener">Quest install page</a>` : 'Quest install page of the DorkNet website';
switch (f.id) {
case 'pc-full': return {
title: 'Full game', tag: 'Start here', primary: true,
body: `Rec Room ${esc(rel.title)} with DorkNet already set up. Unzip it anywhere and run
${rel.pc_exe ? `<code>${esc(rel.pc_exe)}</code>` : 'the game'}.`,
};
case 'pc-patch': return {
title: 'Patch only',
body: `Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have.
The DorkNet launcher installs this for you. Skip it if you got the full game.`,
};
case 'quest': return {
title: 'Quest app', tag: 'Start here', primary: true,
body: `Plug in your headset and install from the ${questPage} (Chrome or Edge), or drop the file onto SideQuest.`,
};
case 'quest-installer': return {
title: 'Install scripts',
body: `Prefer the command line? Unzip these next to the Quest app and run <code>install.cmd</code> (Windows)
or <code>install.sh</code> (macOS / Linux). Needs adb.`,
};
default: return { title: f.name, body: '' };
}
}
function card(f, rel, site) {
const d = describe(f, rel, site);
const build = f.build ? `<span>Build ${esc(f.build)}</span>` : '';
return `
<article class="card${d.primary ? ' primary' : ''}">
<div class="top"><h4>${esc(d.title)}</h4>${d.tag ? `<span class="tag">${esc(d.tag)}</span>` : ''}</div>
<p>${d.body}</p>
<div class="meta"><span class="mono file">${esc(f.name)}</span></div>
<div class="meta"><span>${size(f.size)}</span><span>Updated ${esc(date(f.built_at))}</span>${build}</div>
<div class="actions">
<a class="btn ${d.primary ? 'btn-primary' : 'btn-secondary'}" href="${esc(f.path)}" download>${ICONS.download}Download</a>
<button class="hash mono" type="button" data-sha="${esc(f.sha256)}" title="Copy SHA-256: ${esc(f.sha256)}">${ICONS.copy}SHA-256 ${esc(f.sha256.slice(0, 12))}…</button>
</div>
</article>`;
}
function render(m) {
const root = document.getElementById('releases');
const releases = (m.releases || []).filter((r) => (r.files || []).length);
if (!releases.length) { root.innerHTML = '<p class="state">No builds have been published yet.</p>'; return; }
root.innerHTML = releases.map((rel) => {
const sections = PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return '';
return `
<div class="platform">
<h3>${ICONS[p.id]}${esc(p.title)}</h3>
<p class="hint">${esc(p.hint)}</p>
<div class="grid">${files.map((f) => card(f, rel, m.site_url)).join('')}</div>
</div>`;
}).join('');
const game = rel.game_version ? `Game build of ${esc(date(rel.game_version + 'T12:00:00Z'))}` : '';
return `
<section class="release">
<div class="release-head"><h2>Rec Room ${esc(rel.title)}</h2><span class="sub">${game}</span></div>
${sections}
</section>`;
}).join('');
}
document.addEventListener('click', async (e) => {
const b = e.target.closest('.hash');
if (!b) return;
try {
await navigator.clipboard.writeText(b.dataset.sha);
b.classList.add('copied');
setTimeout(() => b.classList.remove('copied'), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
});
fetch('manifest.json', { cache: 'no-store' })
.then((r) => { if (r.status === 404) return {}; if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(render)
.catch((err) => {
document.getElementById('releases').innerHTML =
`<p class="state error">Couldn't load the list of builds (${esc(err.message)}). Try reloading the page.</p>`;
});
})();
</script>
</body>
</html>
+12 -23
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Publish DorkNet client builds to the downloads page.
"""Publish DorkNet client builds to the downloads bucket.
stage the build output in out/ -> out/downloads/<release>/ under the
names players see, plus release.json describing those files
upload that folder -> the downloads bucket, then merge release.json into
the bucket's manifest.json and upload the page (index.html)
the bucket's manifest.json
The DorkNet server reads the bucket and offers the files on its site (the
Downloads page, and Latest build on the Quest page); see docs/deploy.md.
The build scripts keep their own file names on purpose: the launcher fetches
dorknet-clientpatch-<VersionKey>.zip from GitHub Releases by that exact name.
@@ -15,16 +18,14 @@ Only the published copies are renamed:
DorkNet-Quest-<ver>/DorkNet-<ver>.xapk DorkNet-<release>-Quest.xapk
DorkNet-Quest-<ver>/install.*, README DorkNet-<release>-Quest-Installer.zip
The bucket is served read-only as a static website (Garage's web endpoint).
upload talks to its S3 API with rclone; the endpoint, bucket and key come
from the environment:
The server reads the bucket over plain HTTP on the internal network (e.g.
Garage's web endpoint); nothing needs to be public. upload talks to its S3
API with rclone; the endpoint, bucket and key come from the environment:
DOWNLOADS_S3_ENDPOINT S3 API URL, e.g. http://garage-<uuid>:3900
DOWNLOADS_BUCKET bucket name, e.g. downloads.example.com
DOWNLOADS_BUCKET bucket name, e.g. dorknet-downloads
DOWNLOADS_S3_REGION optional, default garage
DOWNLOADS_S3_KEY_ID / DOWNLOADS_S3_SECRET
DORKNET_SITE_URL optional: the DorkNet server's website, linked
from the page for the browser Quest installer
python3 ci/downloads/publish.py stage --out out --release 2023.03 \\
--title "March 2023" --game-version 2023-03-21 --version-key march_2023_03_21
@@ -44,12 +45,6 @@ import zipfile
from pathlib import Path
from typing import NoReturn
HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
PAGE_FILES = { # bucket path -> source
"index.html": HERE / "index.html",
"dorknet.svg": REPO / "DorkNet.Server" / "site" / "public" / "brand" / "dorknet.svg",
}
NO_CACHE = "Cache-Control: no-cache"
INSTALLER_NOTE = """Unzip these files into the same folder as {xapk}, then
@@ -223,9 +218,6 @@ def merge(manifest: dict, release: dict) -> dict:
releases.sort(key=lambda r: r["id"], reverse=True)
out = {**manifest, "releases": releases,
"updated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()}
site = os.environ.get("DORKNET_SITE_URL", "").rstrip("/")
if site:
out["site_url"] = site
return out
@@ -247,10 +239,7 @@ def cmd_upload(a: argparse.Namespace) -> None:
manifest = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}
manifest = merge(manifest, release)
# 3. the page, then the manifest
for path, src in PAGE_FILES.items():
step(f"uploading {path}")
rclone(env, "copyto", str(src), f"{bucket}/{path}", "--header-upload", NO_CACHE)
# 3. the manifest, last: it is what makes the new files visible
step("uploading manifest.json")
rclone(env, "rcat", f"{bucket}/manifest.json", "--header-upload", NO_CACHE,
"--header-upload", "Content-Type: application/json", stdin=json.dumps(manifest, indent=2) + "\n")
@@ -275,14 +264,14 @@ def main() -> None:
s.add_argument("--out", default="out", help="the build output folder (default: out)")
s.add_argument("--dest", help="where to stage (default: <out>/downloads)")
s.add_argument("--release", required=True, help="release id used in file names and paths, e.g. 2023.03")
s.add_argument("--title", required=True, help='shown on the page, e.g. "March 2023"')
s.add_argument("--title", required=True, help='shown on the Downloads page, e.g. "March 2023"')
s.add_argument("--game-version", required=True, help="the stock build's date, e.g. 2023-03-21")
s.add_argument("--version-key", required=True, help="the PC build's version key, e.g. march_2023_03_21")
s.add_argument("--build", default=os.environ.get("BUILD_NUMBER", ""), help="CI build number (default: $BUILD_NUMBER)")
s.add_argument("--commit", default=os.environ.get("GIT_COMMIT", ""), help="source commit (default: $GIT_COMMIT)")
s.set_defaults(fn=cmd_stage)
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json + page")
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json")
u.add_argument("--staged", default="out/downloads", help="the folder `stage` wrote (default: out/downloads)")
u.set_defaults(fn=cmd_upload)