diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml
new file mode 100644
index 0000000..9ba111e
--- /dev/null
+++ b/.github/workflows/install-test.yml
@@ -0,0 +1,44 @@
+name: Test Patches - Post Build
+
+# Runs automatically after every "Test Release Build" run completes (that build
+# is itself triggered by the 'make-test-build' label on a PR). Reuses the .deb
+# that build produced — no rebuild — installs it on a runner seeded with the
+# latest upstream Proxmox files, and verifies that every mod's patches (and
+# post-apply hooks) apply and revert cleanly. Fails if any mod fails.
+#
+# Wired via workflow_run, so it fires for builds on ANY branch/PR. (GitHub always
+# runs the default-branch copy of this file, so it activates once merged to main.)
+
+on:
+ workflow_run:
+ workflows: ["Test Release Build"]
+ types: [completed]
+
+permissions:
+ contents: read
+ actions: read # required to download the triggering run's artifact
+
+jobs:
+ patch-install-test:
+ runs-on: ubuntu-latest
+ if: github.event.workflow_run.conclusion == 'success'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Download deb from the build run
+ uses: actions/download-artifact@v4
+ with:
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ pattern: pve-mods-test-build-*
+ merge-multiple: true
+ path: deb
+
+ - name: Fetch upstream Proxmox files
+ run: bash src/Scripts/test/fetch-proxmox-files.sh
+
+ - name: Install built package
+ run: sudo apt-get install -y ./deb/*.deb
+
+ - name: Test all mods
+ run: sudo bash src/Scripts/test/test-mods.sh all
diff --git a/.github/workflows/weekly-patch-test.yml b/.github/workflows/weekly-patch-test.yml
new file mode 100644
index 0000000..2b239dd
--- /dev/null
+++ b/.github/workflows/weekly-patch-test.yml
@@ -0,0 +1,110 @@
+name: Test Patches - Weekly
+
+# Weekly regression test of pve-mod against the latest upstream Proxmox VE files.
+# Mods are auto-discovered from the [modules] section of src/pve-mod.conf and
+# tested in parallel - adding a mod needs no change here. If a mod fails, an
+# issue is opened (or updated) per failed mod explaining why.
+
+on:
+ schedule:
+ - cron: '0 6 * * 1' # Mondays 06:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ discover:
+ runs-on: ubuntu-latest
+ outputs:
+ mods: ${{ steps.list.outputs.mods }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Discover mods from [modules]
+ id: list
+ run: |
+ MODS=$(awk -F= '
+ /^\[/ { in_sec = ($0 == "[modules]") }
+ in_sec && /^[^#=]+=/ {
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1
+ }' src/pve-mod.conf | jq -R . | jq -cs .)
+ echo "Discovered mods: $MODS"
+ echo "mods=$MODS" >> "$GITHUB_OUTPUT"
+
+ test:
+ needs: discover
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ mod: ${{ fromJSON(needs.discover.outputs.mods) }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # ── Build (mirrors test-release.yml) ───────────────────────────────────
+ - name: Install build deps
+ run: sudo apt-get install -y devscripts debhelper build-essential
+
+ - name: Inject version into PVEMod_Config.pm
+ run: |
+ VERSION=$(grep -m1 '(' debian/changelog | sed 's/.*(\(.*\)).*/\1/')
+ SHORT_SHA=$(git rev-parse --short HEAD)
+ export BUILD_VERSION="${VERSION}-test-${SHORT_SHA}"
+ perl -i -pe 'BEGIN { $v = $ENV{BUILD_VERSION} }
+ s/(our \$VERSION\s*=\s*'"'"')[^'"'"']*'"'"';/$1'"'"'$v'"'"';/
+ ' src/PVENodeInfo/PVEMod_Config.pm
+
+ - name: Build package
+ run: dpkg-buildpackage -us -uc -b
+
+ # ── Test ───────────────────────────────────────────────────────────────
+ - name: Fetch upstream Proxmox files
+ run: bash src/Scripts/test/fetch-proxmox-files.sh
+
+ - name: Install built package
+ run: sudo apt-get install -y ../pve-mod_*.deb
+
+ - name: Test mod
+ run: |
+ set -o pipefail
+ sudo bash src/Scripts/test/test-mods.sh "${{ matrix.mod }}" 2>&1 | tee test-output.log
+
+ - name: Report failure as issue
+ if: failure()
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ MOD: ${{ matrix.mod }}
+ run: |
+ TITLE="[patch-break] ${MOD}"
+ {
+ echo "The weekly patch test failed for mod \`${MOD}\` against the latest Proxmox VE files."
+ echo ""
+ echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ echo ""
+ echo "Test log
"
+ echo ""
+ echo '```'
+ tail -n 200 test-output.log 2>/dev/null || echo "(no log captured)"
+ echo '```'
+ echo " "
+ } > issue-body.md
+
+ # Ensure the label exists (no-op if it already does).
+ gh label create patch-break --color B60205 \
+ --description "pve-mod patch failed against upstream Proxmox" 2>/dev/null || true
+
+ EXISTING=$(gh issue list --state open --label patch-break \
+ --search "$TITLE in:title" --json number,title \
+ --jq ".[] | select(.title==\"$TITLE\") | .number" | head -n1)
+
+ if [[ -n "$EXISTING" ]]; then
+ gh issue comment "$EXISTING" --body-file issue-body.md
+ echo "Commented on existing issue #$EXISTING"
+ else
+ gh issue create --title "$TITLE" --label patch-break --body-file issue-body.md
+ echo "Opened new issue for mod $MOD"
+ fi
diff --git a/src/Scripts/test/fetch-proxmox-files.sh b/src/Scripts/test/fetch-proxmox-files.sh
new file mode 100644
index 0000000..282cfa7
--- /dev/null
+++ b/src/Scripts/test/fetch-proxmox-files.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# src/Scripts/test/fetch-proxmox-files.sh
+#
+# Downloads the upstream Proxmox VE files that pve-mod patches and installs them
+# at their real system paths, so the patch engine can be exercised on a plain
+# (non-Proxmox) CI runner.
+#
+# Pulls pve-manager and proxmox-widget-toolkit from the Proxmox no-subscription
+# repository (default: PVE 9 / Debian trixie) and extracts the target files.
+#
+# CI-only helper. The repo is added with [trusted=yes]: we are only fetching
+# public, unmodified UI files to patch against, not installing Proxmox, so
+# managing the signing key would add nothing.
+
+set -euo pipefail
+
+SUITE="${PVE_SUITE:-trixie}"
+WORKDIR="$(mktemp -d)"
+trap 'rm -rf "$WORKDIR"' EXIT
+
+# Files provided by the two packages, relative to the filesystem root. These are
+# exactly the paths the .patch files (and the nag_screen post-apply hook) touch.
+FILES=(
+ usr/share/perl5/PVE/API2/Nodes.pm
+ usr/share/pve-manager/js/pvemanagerlib.js
+ usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
+ usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js
+)
+
+echo "[fetch] Configuring Proxmox no-subscription repo ($SUITE)..."
+echo "deb [trusted=yes] http://download.proxmox.com/debian/pve $SUITE pve-no-subscription" \
+ | sudo tee /etc/apt/sources.list.d/pve-test.list >/dev/null
+sudo apt-get update -qq
+
+echo "[fetch] Downloading pve-manager and proxmox-widget-toolkit..."
+cd "$WORKDIR"
+apt-get download pve-manager proxmox-widget-toolkit
+
+for deb in *.deb; do
+ echo "[fetch] Extracting $deb"
+ dpkg-deb -x "$deb" extract
+done
+
+for rel in "${FILES[@]}"; do
+ src="extract/$rel"
+ dst="/$rel"
+ if [[ ! -f "$src" ]]; then
+ echo "[fetch] ERROR: expected file not found in packages: $rel" >&2
+ exit 1
+ fi
+ sudo install -Dm644 "$src" "$dst"
+ echo "[fetch] Installed $dst"
+done
+
+echo "[fetch] Done."
diff --git a/src/Scripts/test/test-mods.sh b/src/Scripts/test/test-mods.sh
new file mode 100644
index 0000000..86d3bf1
--- /dev/null
+++ b/src/Scripts/test/test-mods.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+# src/Scripts/test/test-mods.sh
+#
+# Exercises pve-mod's patch engine against the installed Proxmox files.
+#
+# Usage: test-mods.sh | all
+#
+# Mods are auto-discovered from the [modules] section of the main config, so
+# adding a new mod (a new [modules] key plus a patches// directory) is
+# picked up automatically - no edits to this script are required.
+#
+# For each target mod it:
+# 1. enables only that mod in the main config
+# 2. turns on every conditional flag referenced by the mod's patches.list,
+# so all of the mod's patches are exercised (e.g. node_info's gpu_history)
+# 3. runs apply-patches.sh and asserts it exits 0
+# 4. runs revert-patches.sh and asserts it reports no unclean reversions
+# 5. restores the pristine Proxmox files before moving to the next mod
+#
+# Failure detection relies on the apply exit code: the patch engine performs an
+# atomic preflight dry-run and exits non-zero if any mod cannot apply cleanly.
+#
+# Must run as root (it edits /etc/pve-mod and the patched system files).
+
+set -u
+
+MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}"
+CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}"
+PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}"
+APPLY="${PVE_MOD_APPLY:-/usr/lib/pve-mod/apply-patches.sh}"
+REVERT="${PVE_MOD_REVERT:-/usr/lib/pve-mod/revert-patches.sh}"
+
+# Proxmox files touched by the patches; snapshotted before and restored after
+# each mod so every mod is tested from a pristine baseline.
+PROXMOX_FILES=(
+ /usr/share/perl5/PVE/API2/Nodes.pm
+ /usr/share/pve-manager/js/pvemanagerlib.js
+ /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
+ /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js
+)
+
+SNAP_DIR="$(mktemp -d)"
+trap 'rm -rf "$SNAP_DIR"' EXIT
+
+info() { echo "[test] $*"; }
+warn() { echo "[test] WARNING: $*" >&2; }
+
+# List the keys of the [modules] section in the main config, one per line.
+list_modules() {
+ [[ -f "$MAIN_CONF" ]] || return 0
+ awk -F= '
+ /^\[/ { in_sec = ($0 == "[modules]") }
+ in_sec && /^[^#=]+=/ {
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1)
+ print $1
+ }
+ ' "$MAIN_CONF"
+}
+
+# set_conf : set an existing key within a section.
+set_conf() {
+ local file="$1" section="$2" key="$3" value="$4"
+ [[ -f "$file" ]] || { warn "config not found: $file"; return 1; }
+ awk -v sec="[$section]" -v k="$key" -v v="$value" '
+ /^\[/ { in_sec = ($0 == sec) }
+ {
+ if (in_sec && $0 ~ "^[[:space:]]*"k"[[:space:]]*=") {
+ print k"="v
+ } else {
+ print
+ }
+ }
+ ' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
+}
+
+# Disable every discovered mod in the main config.
+disable_all_modules() {
+ local m
+ for m in $(list_modules); do
+ set_conf "$MAIN_CONF" modules "$m" 0
+ done
+}
+
+# Turn on every conditional flag a mod's patches.list references, so all of its
+# patches become active. Conditions look like: section.key=value
+enable_conditions() {
+ local mod="$1"
+ local manifest="$PATCHES_DIR/$mod/patches.list"
+ local mod_conf="$CONFD_DIR/$mod.conf"
+ [[ -f "$manifest" ]] || return 0
+ local line cond local_key want sect ckey
+ while IFS= read -r line; do
+ line="${line%%#*}"
+ line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"
+ [[ -z "$line" ]] && continue
+ # Skip lines without a condition (no whitespace after the patch name).
+ [[ "$line" == *[[:space:]]* ]] || continue
+ cond="$(echo "${line#*[[:space:]]}" | sed -E 's/^[[:space:]]+//')"
+ [[ "$cond" == *=* ]] || continue
+ local_key="${cond%%=*}"
+ want="${cond#*=}"
+ sect="${local_key%%.*}"
+ ckey="${local_key#*.}"
+ set_conf "$mod_conf" "$sect" "$ckey" "$want" \
+ && info " enabled condition $local_key=$want in $(basename "$mod_conf")"
+ done < "$manifest"
+}
+
+snapshot_files() {
+ rm -rf "$SNAP_DIR"/*; mkdir -p "$SNAP_DIR"
+ local i=0 f
+ for f in "${PROXMOX_FILES[@]}"; do
+ if [[ -e "$f" || -L "$f" ]]; then
+ cp -a "$f" "$SNAP_DIR/$i"
+ fi
+ i=$((i+1))
+ done
+}
+
+restore_files() {
+ local i=0 f
+ for f in "${PROXMOX_FILES[@]}"; do
+ if [[ -e "$SNAP_DIR/$i" || -L "$SNAP_DIR/$i" ]]; then
+ rm -f "$f"
+ cp -a "$SNAP_DIR/$i" "$f"
+ fi
+ i=$((i+1))
+ done
+}
+
+# Test a single mod end to end. Returns 0 on success, 1 on failure.
+test_one_mod() {
+ local mod="$1"
+ echo "::group::Testing mod: $mod"
+ local ok=0
+
+ if [[ ! -f "$PATCHES_DIR/$mod/patches.list" ]]; then
+ warn "no patch manifest for mod '$mod' ($PATCHES_DIR/$mod/patches.list)"
+ echo "::endgroup::"
+ return 1
+ fi
+
+ disable_all_modules
+ set_conf "$MAIN_CONF" modules "$mod" 1
+ enable_conditions "$mod"
+ snapshot_files
+
+ info "Applying patches for '$mod'..."
+ local apply_log apply_rc
+ apply_log="$("$APPLY" 2>&1)"; apply_rc=$?
+ echo "$apply_log"
+ if [[ $apply_rc -ne 0 ]]; then
+ warn "apply-patches.sh failed for mod '$mod' (exit $apply_rc)"
+ ok=1
+ fi
+
+ if [[ $ok -eq 0 ]]; then
+ info "Reverting patches for '$mod'..."
+ local revert_log
+ revert_log="$("$REVERT" 2>&1)"
+ echo "$revert_log"
+ if echo "$revert_log" | grep -qE "could not be reverted cleanly|reported an error"; then
+ warn "revert-patches.sh reported an unclean revert for mod '$mod'"
+ ok=1
+ fi
+ fi
+
+ restore_files
+
+ if [[ $ok -eq 0 ]]; then
+ info "PASS: $mod"
+ else
+ warn "FAIL: $mod"
+ fi
+ echo "::endgroup::"
+ return $ok
+}
+
+# ── main ──────────────────────────────────────────────────────────────────────
+[[ $# -eq 1 ]] || { echo "Usage: $0 |all" >&2; exit 2; }
+
+if ! command -v patch >/dev/null 2>&1; then
+ warn "'patch' command not found; install the 'patch' package."
+ exit 1
+fi
+
+targets=()
+if [[ "$1" == "all" ]]; then
+ mapfile -t targets < <(list_modules)
+ [[ ${#targets[@]} -gt 0 ]] || { warn "no mods found in [modules] of $MAIN_CONF"; exit 1; }
+else
+ targets=("$1")
+fi
+
+failed=()
+for mod in "${targets[@]}"; do
+ test_one_mod "$mod" || failed+=("$mod")
+done
+
+echo ""
+if [[ ${#failed[@]} -gt 0 ]]; then
+ warn "Failed mods: ${failed[*]}"
+ exit 1
+fi
+info "All tested mods passed: ${targets[*]}"
+exit 0