ci: build the PC and Quest clients on Jenkins

A Jenkinsfile builds, on push (only what changed) or on demand:
- the PC patcher zip (MelonLoader, the mod, config template, Steam stub),
- the full PC client (stock build + patcher + config),
- the Quest APK and .xapk.

Stock builds come out of the restic build archive on the FTP: restic
through its rclone backend, picked by snapshot tag per run and cached.
The FTP login and restic password are Jenkins credentials, and the
repository folders are environment settings, so nothing about the
archive is fixed in the repo.

ci/jenkins/Dockerfile is the Jenkins image with the toolchain added
(.NET SDK, Android NDK and build-tools, apktool, Python with LIEF,
restic, rclone), for the existing Jenkins service.

The build scripts move from PowerShell to Python so they run the same
on Windows, Linux and in CI:
- DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it
  can use a committed hook table (checked against the APK's
  libil2cpp.so) instead of dump.cs, and takes a persistent keystore.
- tools/build_pc.py replaces build-patcher-zip.ps1 (now with the
  2023 install layout) and adds the full-client build.

Setup steps are in ci/jenkins/README.md.
This commit is contained in:
Alexa
2026-10-24 20:26:42 +03:00
parent 38335c06a9
commit b1c1239165
14 changed files with 1304 additions and 596 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside the Linux image; keep them LF on every checkout.
*.sh text eol=lf
+83
View File
@@ -0,0 +1,83 @@
# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest
# client builds need, so the job (the Jenkinsfile at the repo root) runs right
# on the controller. Jobs, plugins and settings stay in the jenkins_home
# volume; only the image changes.
#
# PC Python + .NET SDK tools/build_pc.py
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
# DorkNet.Quest/scripts/build.py
# Builds restic + rclone stock builds restored from the restic
# archive on the FTP (archive-restore.sh)
#
# See ci/jenkins/README.md.
# Match this to the Jenkins image your Dokploy service runs today, so the
# upgrade keeps the same Jenkins version and Java: e.g.
# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17
ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17
FROM ${JENKINS_IMAGE}
USER root
# ---- base tools + .NET SDK (Microsoft's Debian feed for the image's own
# Debian release) -----------------------------------------------------------
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl wget unzip zip bzip2 python3 python3-venv \
&& . /etc/os-release \
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
&& apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
# build.py runs its helpers with -I (isolated), which ignores PYTHONPATH and
# user site-packages, so LIEF goes into the interpreter that is first on PATH.
RUN python3 -m venv /opt/py \
&& /opt/py/bin/pip install --no-cache-dir lief==0.15.1
ENV PATH="/opt/py/bin:${PATH}"
# ---- Android SDK: build-tools (zipalign, apksigner, aapt2) + NDK ----------
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=11076708
ARG BUILD_TOOLS=34.0.0
ARG NDK=27.2.12479018
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/clt.zip \
&& unzip -q /tmp/clt.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/clt.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& (yes || true) | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install \
"build-tools;${BUILD_TOOLS}" "ndk;${NDK}" > /dev/null \
&& chown -R jenkins:jenkins ${ANDROID_SDK_ROOT}
ENV ANDROID_NDK=${ANDROID_SDK_ROOT}/ndk/${NDK}
# ---- apktool ---------------------------------------------------------------
ARG APKTOOL=2.9.3
RUN wget -q https://github.com/iBotPeaches/Apktool/releases/download/v${APKTOOL}/apktool_${APKTOOL}.jar \
-O ${ANDROID_SDK_ROOT}/apktool.jar
ENV APKTOOL_JAR=${ANDROID_SDK_ROOT}/apktool.jar
# ---- restic + rclone (restic reaches the FTP through rclone) --------------
ARG RESTIC=0.18.1
ARG RCLONE=1.68.2
RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic_${RESTIC}_linux_amd64.bz2 -O /tmp/restic.bz2 \
&& bunzip2 /tmp/restic.bz2 \
&& install -m 0755 /tmp/restic /usr/local/bin/restic && rm /tmp/restic \
&& wget -q https://downloads.rclone.org/v${RCLONE}/rclone-v${RCLONE}-linux-amd64.zip -O /tmp/rclone.zip \
&& unzip -q /tmp/rclone.zip -d /tmp/rclone \
&& install -m 0755 /tmp/rclone/rclone-v${RCLONE}-linux-amd64/rclone /usr/local/bin/rclone \
&& rm -rf /tmp/rclone /tmp/rclone.zip
COPY archive-restore.sh /usr/local/bin/archive-restore
RUN chmod +x /usr/local/bin/archive-restore
# Restored stock builds and the restic cache live in the persistent
# jenkins_home volume, so each multi-GB build is downloaded once.
ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1
USER jenkins
+140
View File
@@ -0,0 +1,140 @@
# Client builds on Jenkins
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
Jenkins.
It runs right on the Jenkins container, so that container's image needs the
build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools,
apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock
Jenkins image with that toolchain added. Your jobs, plugins and settings live
in the `jenkins_home` volume, not in the image, so switching images leaves
them alone.
| Platform | Output (Jenkins artifacts) | How |
|---|---|---|
| PC | `out/pc/dorknet-clientpatch-march_2023_03_21.zip`: MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` |
| PC | `out/pc/dorknet-client-march_2023_03_21.zip`: the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` |
| Quest | `out/quest/dorknet-recroom-<code>.apk` and `out/quest/DorkNet-Quest-<ver>/` (`.xapk` + installers) | `DorkNet.Quest/scripts/build.py build` |
The full PC client and the Quest packages contain game files, so keep them on
Jenkins. Don't publish them anywhere public.
## Where the stock builds come from
The stock builds live in restic repositories on the archive FTP. They are the
same repositories as the local archive's `v2/steam-v2` and `v2/quest-v2`. restic
reaches the FTP through its rclone backend (`ci/jenkins/archive-restore.sh`).
A build is picked by **snapshot tags**:
| Repository | Tags (examples) |
|---|---|
| Steam | `manifest=<depot manifest id>`, `date=…`, `branch=public` |
| Quest | `version=20230317`, `version-code=2128210` |
The job defaults to the builds this branch targets: Steam manifest
`7490748483298966814` (2023-03-21) and Quest `version-code=2128210`. Any other
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
matching a filter without building anything.
A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock
build is downloaded once.
A Quest build needs a resolved hook table for its versionCode, committed under
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
checks the table against the APK's `libil2cpp.so` and stops on a mismatch.
Resolve a table for another build locally with `build.py build --dump-cs`.
## Triggers
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
- `DorkNet.Quest/` rebuilds Quest;
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
- **Build with Parameters** runs a manual build:
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
- snapshot tags, and per-run repository overrides.
## Setup
### 1. Switch your Jenkins to this image
On your Jenkins service in Dokploy:
1. **Build from this repo.**
- For an Application service: provider GitHub, branch `march-2023-03-21`,
build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build
context `ci/jenkins`.
- For a Compose service: change its `jenkins` service to `build:` from
`ci/jenkins` as in `ci/jenkins/docker-compose.yml`.
- Either way, **keep the same volume on `/var/jenkins_home`**.
2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the
image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`).
The default is `jenkins/jenkins:lts-jdk17`.
3. **Add the environment:**
| Variable | Value |
|---|---|
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and
takes a while.
### 2. Plugins
The job needs these plugins:
- Pipeline
- Git
- GitHub (for the push trigger)
- Credentials Binding
- Timestamper
- AnsiColor
- Workspace Cleanup
Most are part of the suggested set.
### 3. Credentials
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
| ID | Kind | Value |
|---|---|---|
| `archive-ftp` | Username with password | the FTP login |
| `archive-ftp-host` | Secret text | FTP host name only (no `ftp://`, no user, no path) |
| `archive-restic-password` | Secret text | the archive's restic password |
| `quest-keystore` | Secret file | the Quest signing keystore |
| `quest-keystore-password` | Secret text | its store/key password |
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
| `quest-config` | Secret file | your `dorknet-quest.cfg` |
**Use the keystore your players' installs were signed with**, the
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
different key means every player has to uninstall before installing a CI
build.
### 4. The job
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
2. Under **Pipeline**, choose **Pipeline script from SCM**:
- Git URL `https://github.com/DorkSquadRR/DorkNet.git`
- branch `*/march-2023-03-21`
- script path `Jenkinsfile`
3. Under **Triggers**, tick **GitHub hook trigger for GITScm polling**.
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
offer **Build with Parameters**.
### 5. GitHub webhook
In the repository's **Settings → Webhooks**:
- Payload URL: `https://<your jenkins>/github-webhook/`
- Content type: `application/json`
- Events: just the push event
### Disk
The restored stock builds are cached in `jenkins_home/dorknet-cache`, several
GB each. Jenkins keeps the artifacts of the last 3 runs.
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# archive-restore — pull a Rec Room build out of the restic build archive.
#
# archive-restore list <repo-path> [tag[,tag…]]
# archive-restore restore <repo-path> <tag[,tag…]> <cache-dir> [restic restore args…]
#
# <repo-path> is the restic repository's folder on the archive FTP, e.g.
# "Rec Room Versions/steam-v2". Snapshots are picked by tag, e.g.
# "manifest=7490748483298966814" (Steam) or "version-code=2128210" (Quest);
# several tags separated by commas must all match. The newest match wins.
#
# `restore` writes the snapshot to <cache-dir>/<snapshot-id>/ and prints that
# path. A snapshot already restored there is reused, so a multi-GB build is
# only downloaded once. Extra arguments go to `restic restore` (e.g.
# --exclude /.DepotDownloader).
#
# restic reaches the FTP through its rclone backend. Everything secret comes
# from the environment (Jenkins injects it from credentials):
# ARCHIVE_FTP_HOST FTP host name
# ARCHIVE_FTP_USER FTP user
# ARCHIVE_FTP_PASSWORD FTP password
# RESTIC_PASSWORD restic repository password
# Optional: ARCHIVE_FTP_PORT (21), ARCHIVE_FTP_TLS (none | explicit | implicit,
# default explicit), RESTIC_CACHE_DIR.
set -euo pipefail
mode="${1:?usage: archive-restore list|restore <repo-path> …}"
repo_path="${2:?repo path required}"
tags="${3:-}"
: "${ARCHIVE_FTP_HOST:?ARCHIVE_FTP_HOST is not set}"
: "${ARCHIVE_FTP_USER:?ARCHIVE_FTP_USER is not set}"
: "${ARCHIVE_FTP_PASSWORD:?ARCHIVE_FTP_PASSWORD is not set}"
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD is not set}"
# rclone remote "rrarchive", configured purely through its env-var config.
export RCLONE_CONFIG_RRARCHIVE_TYPE=ftp
export RCLONE_CONFIG_RRARCHIVE_HOST="$ARCHIVE_FTP_HOST"
export RCLONE_CONFIG_RRARCHIVE_USER="$ARCHIVE_FTP_USER"
export RCLONE_CONFIG_RRARCHIVE_PORT="${ARCHIVE_FTP_PORT:-21}"
RCLONE_CONFIG_RRARCHIVE_PASS="$(printf '%s' "$ARCHIVE_FTP_PASSWORD" | rclone obscure -)"
export RCLONE_CONFIG_RRARCHIVE_PASS
case "${ARCHIVE_FTP_TLS:-explicit}" in
explicit) export RCLONE_CONFIG_RRARCHIVE_EXPLICIT_TLS=true ;;
implicit) export RCLONE_CONFIG_RRARCHIVE_TLS=true ;;
none) ;;
*) echo "archive-restore: ARCHIVE_FTP_TLS must be none, explicit or implicit" >&2; exit 2 ;;
esac
export RESTIC_REPOSITORY="rclone:rrarchive:${repo_path}"
tag_args=()
[ -n "$tags" ] && tag_args=(--tag "$tags")
case "$mode" in
list)
restic snapshots --no-lock "${tag_args[@]}"
;;
restore)
cache="${4:?cache dir required}"
[ -n "$tags" ] || { echo "archive-restore: restore needs a tag filter" >&2; exit 2; }
shift 4
id="$(restic snapshots --no-lock --json "${tag_args[@]}" \
| python3 -c 'import json,sys; s=sorted(json.load(sys.stdin), key=lambda x: x["time"]); print(s[-1]["short_id"] if s else "")')"
if [ -z "$id" ]; then
echo "archive-restore: no snapshot in '$repo_path' has tags '$tags'" >&2
exit 1
fi
dest="$cache/$id"
if [ -f "$dest.restored" ]; then
echo "archive-restore: reusing $id from $dest" >&2
else
echo "archive-restore: restoring $id ($tags) from '$repo_path'" >&2
rm -rf "$dest" "$dest.restored"
mkdir -p "$dest"
restic restore --no-lock "$id" --target "$dest" "$@" >&2
touch "$dest.restored"
fi
echo "$dest"
;;
*)
echo "archive-restore: unknown mode '$mode'" >&2
exit 2
;;
esac
+31
View File
@@ -0,0 +1,31 @@
# Reference for the Jenkins service on Dokploy, built from this directory's
# Dockerfile (the stock Jenkins image plus the client build toolchain).
#
# Use it on your EXISTING Jenkins service rather than as a second Jenkins.
# The data volume must be the one Jenkins uses today, or Jenkins starts empty.
# Point `volumes:` at that volume (and its name under the top-level
# `volumes:`, marked `external: true`), or keep this service's name and volume
# if they already match. See ci/jenkins/README.md.
services:
jenkins:
build:
context: .
dockerfile: Dockerfile
args:
# The image your Jenkins runs today, so the Jenkins version and Java
# stay the same.
JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17}
restart: unless-stopped
expose:
- "8080"
environment:
# Where the stock builds live in the archive (restic repository folders
# on the FTP), as plain text. Overridable per run.
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
volumes:
- jenkins_home:/var/jenkins_home
volumes:
jenkins_home: