diff --git a/.github/workflows/scripts/qemu-xfstests-bake-vm.sh b/.github/workflows/scripts/qemu-xfstests-bake-vm.sh
new file mode 100755
index 000000000000..177acc5bbfa6
--- /dev/null
+++ b/.github/workflows/scripts/qemu-xfstests-bake-vm.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+######################################################################
+# Bake xfstests into the build VM image.
+#
+# Runs on the build machine (vm0) AFTER ZFS has been built and installed
+# (qemu-4-build.sh, without --poweroff)
+# Called on the runner as:
+# ssh zfs@vm0 '$HOME/zfs/.github/workflows/scripts/qemu-xfstests-bake-vm.sh' $OS
+#
+# The script powers the VM off at the end (like qemu-4-build-vm.sh --poweroff)
+######################################################################
+
+set -eu
+
+OS="$1"
+
+# TODO: move the sources under openzfs
+XFSTESTS_REPO="${XFSTESTS_REPO:-https://github.com/implr/xfstests}"
+XFSTESTS_BRANCH="${XFSTESTS_BRANCH:-zfs}"
+
+echo "##[group]Install xfstests dependencies"
+case "$OS" in
+ debian*|ubuntu*)
+ sudo apt-get update
+ # Build toolchain + xfstests build/runtime deps
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
+ git build-essential autoconf automake libtool pkg-config gettext \
+ uuid-dev libattr1-dev libacl1-dev libaio-dev libgdbm-dev libssl-dev \
+ xfsprogs e2fsprogs attr acl quota gdisk parted
+ # optional extras for a couple tests
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y fio dbench || true
+ ;;
+ *)
+ echo "xfstests bake is only implemented for debian/ubuntu so far" >&2
+ exit 1
+ ;;
+esac
+echo "##[endgroup]"
+
+echo "##[group]Create xfstests service users"
+# xfstests insists on these accounts. Digit-leading name
+# needs --badnames on modern shadow-utils.
+sudo groupadd -f fsgqa
+for u in fsgqa fsgqa2 123456-fsgqa; do
+ if ! id "$u" &>/dev/null; then
+ sudo useradd --badnames -g fsgqa -m "$u" 2>/dev/null \
+ || sudo useradd -g fsgqa -m "$u" 2>/dev/null || true
+ fi
+done
+
+# xfstests runs as root and drops to fsgqa via `su` (common/rc _user_do/_su).
+# Installing OpenZFS pulls in libpam-zfs, which registers pam_zfs_key.so in the
+# PAM stack to unlock users' encrypted home datasets at login. Its *session*
+# hook prompts "Password:" when root su's to fsgqa (to derive the key for the
+# nonexistent rpool/home/fsgqa), and that prompt lands in test output and fails
+# every _user_do-based test (generic/123, 128, 314, ...). We don't use encrypted
+# homes here, so strip pam_zfs_key from the PAM config.
+sudo pam-auth-update --package --remove zfs_key 2>/dev/null || true
+sudo sed -i '/pam_zfs_key/d' \
+ /etc/pam.d/common-auth /etc/pam.d/common-session \
+ /etc/pam.d/common-password /etc/pam.d/common-account 2>/dev/null || true
+echo "##[endgroup]"
+
+echo "##[group]Clone + build xfstests ($XFSTESTS_BRANCH)"
+rm -rf "$HOME/xfstests"
+git clone --depth 1 -b "$XFSTESTS_BRANCH" "$XFSTESTS_REPO" "$HOME/xfstests"
+cd "$HOME/xfstests"
+make -j"$(nproc)"
+echo "xfstests baked at $HOME/xfstests ($(git rev-parse --short HEAD))"
+echo "##[endgroup]"
+
+# reset cloud-init and power off
+sudo cloud-init clean --logs
+sync && sleep 2 && sudo poweroff &
+exit 0
diff --git a/.github/workflows/scripts/qemu-xfstests-prepare.sh b/.github/workflows/scripts/qemu-xfstests-prepare.sh
new file mode 100755
index 000000000000..33d5993c121a
--- /dev/null
+++ b/.github/workflows/scripts/qemu-xfstests-prepare.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+
+######################################################################
+# Collect xfstests results off the test VM, tar them for upload, and
+# write a short GitHub job summary. Runs on the runner; always() in the
+# workflow so we still grab logs (and the console) on a guest crash.
+######################################################################
+
+set -eu
+
+source /var/tmp/env.txt
+RES="$RESPATH" # /var/tmp/test_results
+mkdir -p "$RES"
+
+# Pull results + logs off vm1 (best-effort; the VM may have crashed).
+rsync -arL zfs@vm1:xfstests/results "$RES/" 2>/dev/null || true
+scp zfs@vm1:xfstests/local.config "$RES/" 2>/dev/null || true
+scp 'zfs@vm1:/var/tmp/dmesg-*.txt' "$RES/" 2>/dev/null || true
+scp 'zfs@vm1:/var/tmp/tests-exitcode.txt' "$RES/" 2>/dev/null || true
+cp -f /var/tmp/xfstests-run.log "$RES/" 2>/dev/null || true
+# qemu-5-setup.sh already streams the VM serial console to $RES/vm1/console.txt.
+
+TARNAME="xfstests-$OS"
+TAR="/tmp/$TARNAME.tar.bz2"
+mv "$RES" "$(dirname "$RES")/$TARNAME"
+tar cjf "$TAR" -C "$(dirname "$RES")" -h "$TARNAME" || true
+mv "$(dirname "$RES")/$TARNAME" "$RES"
+
+# --- Job summary ----------------------------------------------------
+SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
+{
+ echo "## xfstests on ${OSNAME:-$OS}"
+ echo
+ if [ -f "$RES/tests-exitcode.txt" ]; then
+ rv=$(cat "$RES/tests-exitcode.txt")
+ if [ "$rv" = "0" ]; then
+ echo ":thumbsup: \`./check\` exited 0 — all selected tests passed."
+ else
+ echo ":warning: \`./check\` exited $rv — failures (or a notrun treated as error)."
+ fi
+ else
+ echo ":interrobang: no exit code recorded — the VM may have crashed mid-run."
+ fi
+ echo
+
+ # xfstests prints a "Failures:" / "Passed all N tests" line near the end of
+ # results/check.log. Surface the tail.
+ if [ -f "$RES/results/check.log" ]; then
+ echo 'check.log (tail)
'
+ echo
+ echo '```'
+ tail -n 40 "$RES/results/check.log"
+ echo '```'
+ echo ' '
+ fi
+} >> "$SUMMARY" 2>/dev/null || true
+
+exit 0
diff --git a/.github/workflows/scripts/qemu-xfstests-run.sh b/.github/workflows/scripts/qemu-xfstests-run.sh
new file mode 100755
index 000000000000..ca423ace75b9
--- /dev/null
+++ b/.github/workflows/scripts/qemu-xfstests-run.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+
+######################################################################
+# Run xfstests
+#
+# - called with 'guest' -> runs inside the test VM (vm1)
+# called on runner: qemu-xfstests-run.sh
+# called on qemu-vm: qemu-xfstests-run.sh guest
+#
+######################################################################
+
+set -eu
+
+############################
+# Runner side (no args)
+############################
+if [ -z "${1:-}" ]; then
+ source /var/tmp/env.txt
+ SCRIPT='$HOME/zfs/.github/workflows/scripts/qemu-xfstests-run.sh'
+ # Pass the user ./check arguments through to the guest.
+ ssh zfs@vm1 "OS='$OS' XFSTESTS_OPTIONS='${XFSTESTS_OPTIONS:-}' $SCRIPT guest" \
+ 2>&1 | stdbuf -oL tee /var/tmp/xfstests-run.log
+ exit "${PIPESTATUS[0]}"
+fi
+
+############################
+# VM side
+############################
+OS="${OS:?}"
+OPTS="${XFSTESTS_OPTIONS:--g quick}"
+export PATH="$PATH:/sbin:/usr/sbin:/usr/local/sbin:/usr/local/bin"
+
+sudo -E modprobe zfs
+
+# Longer RCU timeouts (as in qemu-6-tests.sh)
+rcu="/sys/module/rcupdate/parameters/rcu_cpu_stall_timeout"
+test -f "$rcu" && echo 120 | sudo tee "$rcu" >/dev/null || true
+
+# Carve the 64 GiB tests disk (attached by qemu-5-setup.sh as the 2nd virtio
+# disk) into a TEST_DEV partition + three SCRATCH pool members.
+DEV=/dev/vdb
+sudo wipefs -a "$DEV" || true
+sudo sgdisk -Z "$DEV"
+sudo sgdisk -n1:0:+20G -n2:0:+14G -n3:0:+14G -n4:0:0 "$DEV"
+sudo partprobe "$DEV"
+sleep 2
+
+# Persistent test pool/dataset.
+# (acltype=posix + xattr=sa are required for several generic/ tests).
+sudo zpool create -f \
+ -O mountpoint=legacy -O acltype=posix -O xattr=sa \
+ -O compression=off -O relatime=off \
+ testpool "${DEV}1"
+sudo zfs create -o mountpoint=legacy -o recordsize=64K testpool/testfs
+
+sudo mkdir -p /mnt/test /mnt/scratch
+sudo mount -t zfs testpool/testfs /mnt/test
+
+cd "$HOME/xfstests"
+cat > local.config < /var/tmp/dmesg-prerun.txt || true
+
+RV=0
+# shellcheck disable=SC2086 # word-splitting of OPTS/EXCLUDE is intentional
+sudo HOST_OPTIONS="$PWD/local.config" ./check $EXCLUDE $OPTS || RV=$?
+echo "$RV" | sudo tee /var/tmp/tests-exitcode.txt >/dev/null
+
+sudo dmesg > /var/tmp/dmesg-postrun.txt || true
+sync
+exit "$RV"
diff --git a/.github/workflows/zfs-qemu-xfstests.yml b/.github/workflows/zfs-qemu-xfstests.yml
new file mode 100644
index 000000000000..4f7196df75ea
--- /dev/null
+++ b/.github/workflows/zfs-qemu-xfstests.yml
@@ -0,0 +1,77 @@
+name: zfs-qemu-xfstests
+
+on:
+ workflow_dispatch:
+ inputs:
+ os:
+ type: string
+ required: false
+ default: "ubuntu24"
+ description: "Distribution to test on (e.g. ubuntu24, debian13)"
+ xfstests_options:
+ type: string
+ required: false
+ default: "-g quick"
+ description: "Arguments for xfstests' ./check (e.g. '-g quick', '-g auto', 'generic/467')"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ xfstests:
+ name: xfstests-${{ inputs.os }}
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup QEMU
+ timeout-minutes: 60
+ run: .github/workflows/scripts/qemu-1-setup.sh
+
+ - name: Start build machine
+ timeout-minutes: 10
+ run: |
+ .github/workflows/scripts/qemu-2-start.sh ${{ inputs.os }}
+ # force single vm for now
+ sed -i 's/^VMs=.*/VMs="1"/' /var/tmp/env.txt
+
+ - name: Install dependencies
+ timeout-minutes: 60
+ run: .github/workflows/scripts/qemu-3-deps.sh --poweroff ${{ inputs.os }}
+
+ # NOTE: no --poweroff here, unlike zfs-qemu.yml — we keep vm0 up so the
+ # bake step can install xfstests before the image is snapshotted.
+ - name: Build modules
+ timeout-minutes: 30
+ run: .github/workflows/scripts/qemu-4-build.sh --enable-debug ${{ inputs.os }}
+
+ - name: Bake xfstests into image
+ timeout-minutes: 30
+ run: ssh zfs@vm0 '$HOME/zfs/.github/workflows/scripts/qemu-xfstests-bake-vm.sh' ${{ inputs.os }}
+
+ - name: Setup testing machines
+ timeout-minutes: 5
+ run: .github/workflows/scripts/qemu-5-setup.sh
+
+ - name: Run xfstests
+ timeout-minutes: 270
+ run: .github/workflows/scripts/qemu-xfstests-run.sh
+ env:
+ XFSTESTS_OPTIONS: ${{ inputs.xfstests_options }}
+
+ - name: Prepare artifacts
+ if: always()
+ timeout-minutes: 10
+ run: .github/workflows/scripts/qemu-xfstests-prepare.sh
+
+ - uses: actions/upload-artifact@v7
+ id: artifact-upload
+ if: always()
+ with:
+ name: xfstests-${{ inputs.os }}
+ path: /tmp/xfstests-${{ inputs.os }}.tar.bz2
+ archive: false
+ if-no-files-found: ignore