diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh
index 8f47b3dea6a..78ecb28965e 100755
--- a/.github/workflows/wheels-dependencies.sh
+++ b/.github/workflows/wheels-dependencies.sh
@@ -206,6 +206,12 @@ function build_libavif {
             -DAVIF_CODEC_DAV1D=LOCAL
         )
     fi
+    # libaom's riscv64 RVV code calls functions without declarations, which
+    # GCC 14 (manylinux_2_39) treats as errors. Disable arch-specific AOM
+    # optimizations for riscv64; QEMU-based builds don't benefit from them.
+    if [[ "$(uname -m)" == "riscv64" ]]; then
+        libavif_cmake_flags+=(-DAOM_TARGET_CPU=generic)
+    fi
 
     local out_dir=$(fetch_unpack https://github.com/AOMediaCodec/libavif/archive/refs/tags/v$LIBAVIF_VERSION.tar.gz libavif-$LIBAVIF_VERSION.tar.gz)
 
@@ -269,6 +275,13 @@ function build {
     fi
     build_simple libxcb $LIBXCB_VERSION https://www.x.org/releases/individual/lib
 
+    # libjpeg-turbo 3.1.4.1 simdcoverage.c references riscv64 RVV SIMD
+    # functions that are only in upstream main and not yet released. Disable
+    # SIMD for riscv64 to avoid the build error; there is no production
+    # riscv64 SIMD support in this version anyway.
+    if [[ "$(uname -m)" == "riscv64" ]]; then
+        HOST_CMAKE_FLAGS="${HOST_CMAKE_FLAGS} -DWITH_SIMD=FALSE"
+    fi
     build_libjpeg_turbo
     if [[ -n "$IS_MACOS" ]]; then
         # Custom tiff build to include jpeg; by default, configure won't include
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 959fd0e26d7..a02c08b3081 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -36,7 +36,7 @@ concurrency:
   cancel-in-progress: true
 
 env:
-  EXPECTED_DISTS: 87
+  EXPECTED_DISTS: 89
   FORCE_COLOR: 1
 
 jobs:
@@ -91,6 +91,12 @@ jobs:
             os: ubuntu-24.04-arm
             cibw_arch: aarch64
             build: "*musllinux*"
+          - name: "manylinux_2_39 riscv64"
+            platform: linux
+            os: ubuntu-latest
+            cibw_arch: riscv64
+            build: "cp3{13,14}-manylinux*"
+            manylinux: "manylinux_2_39"
           - name: "iOS arm64 device"
             platform: ios
             os: macos-latest
@@ -113,6 +119,12 @@ jobs:
         with:
           python-version: "3.x"
 
+      - name: Set up QEMU
+        if: matrix.cibw_arch == 'riscv64'
+        uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
+        with:
+          platforms: riscv64
+
       - name: Install cibuildwheel
         run: |
           python3 -m pip install -r .ci/requirements-cibw.txt
@@ -126,6 +138,7 @@ jobs:
           CIBW_BUILD: ${{ matrix.build }}
           CIBW_ENABLE: cpython-prerelease pypy
           CIBW_ENVIRONMENT_PASS_LINUX: FORCE_COLOR
+          CIBW_MANYLINUX_RISCV64_IMAGE: ${{ matrix.manylinux }}
           MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
 
       - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
diff --git a/Tests/helper.py b/Tests/helper.py
index d77b4b807ec..0ce778a5295 100644
--- a/Tests/helper.py
+++ b/Tests/helper.py
@@ -163,8 +163,11 @@ def assert_tuple_approx_equal(
             pytest.fail(msg + ": " + repr(actuals) + " != " + repr(targets))
 
 
-def timeout_unless_slower_valgrind(timeout: float) -> pytest.MarkDecorator:
-    if "PILLOW_VALGRIND_TEST" in os.environ:
+def timeout_unless_slower(timeout: float) -> pytest.MarkDecorator:
+    if (
+        "PILLOW_VALGRIND_TEST" in os.environ
+        or os.environ.get("AUDITWHEEL_ARCH") == "riscv64"
+    ):
         return pytest.mark.pil_noop_mark()
     return pytest.mark.timeout(timeout)
 
diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py
index d41bab30741..26860f6bd3e 100644
--- a/Tests/test_file_eps.py
+++ b/Tests/test_file_eps.py
@@ -16,7 +16,7 @@
     is_win32,
     mark_if_feature_version,
     skip_unless_feature,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 HAS_GHOSTSCRIPT = EpsImagePlugin.has_ghostscript()
@@ -411,7 +411,7 @@ def test_emptyline() -> None:
     assert image.format == "EPS"
 
 
-@timeout_unless_slower_valgrind(5)
+@timeout_unless_slower(5)
 @pytest.mark.parametrize(
     "test_file",
     ["Tests/images/eps/timeout-d675703545fee17acab56e5fec644c19979175de.eps"],
diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py
index 13c6a43239b..3a34f2d02ed 100644
--- a/Tests/test_file_fli.py
+++ b/Tests/test_file_fli.py
@@ -11,7 +11,7 @@
     assert_image_equal,
     assert_image_equal_tofile,
     is_pypy,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 # created as an export of a palette image from Gimp2.6
@@ -196,7 +196,7 @@ def test_seek() -> None:
         "Tests/images/timeout-bff0a9dc7243a8e6ede2408d2ffa6a9964698b87.fli",
     ],
 )
-@timeout_unless_slower_valgrind(3)
+@timeout_unless_slower(3)
 def test_timeouts(test_file: str) -> None:
     with open(test_file, "rb") as f:
         with Image.open(f) as im:
diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index 145916ed87d..86f6df3971b 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -31,7 +31,7 @@
     is_win32,
     mark_if_feature_version,
     skip_unless_feature,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 ElementTree: ModuleType | None
@@ -1048,7 +1048,7 @@ def test_save_xmp(self, tmp_path: Path) -> None:
         with pytest.raises(ValueError):
             im.save(f, xmp=b"1" * 65505)
 
-    @timeout_unless_slower_valgrind(1)
+    @timeout_unless_slower(1)
     def test_eof(self, monkeypatch: pytest.MonkeyPatch) -> None:
         # Even though this decoder never says that it is finished
         # the image should still end when there is no new data
diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py
index 8bb4ff8db92..098746bbb32 100644
--- a/Tests/test_file_pdf.py
+++ b/Tests/test_file_pdf.py
@@ -17,7 +17,7 @@
     hopper,
     mark_if_feature_version,
     skip_unless_feature,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 
@@ -345,7 +345,7 @@ def test_pdf_append_to_bytesio() -> None:
     assert len(f.getvalue()) > initial_size
 
 
-@timeout_unless_slower_valgrind(1)
+@timeout_unless_slower(1)
 @pytest.mark.parametrize("newline", (b"\r", b"\n"))
 def test_redos(newline: bytes) -> None:
     malicious = b" trailer<<>>" + newline * 3456
diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py
index e442471d1ca..0a220c4862e 100644
--- a/Tests/test_file_tiff.py
+++ b/Tests/test_file_tiff.py
@@ -28,7 +28,7 @@
     hopper,
     is_pypy,
     is_win32,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 ElementTree: ModuleType | None
@@ -1015,7 +1015,7 @@ def test_string_dimension(self) -> None:
             with pytest.raises(OSError):
                 im.load()
 
-    @timeout_unless_slower_valgrind(6)
+    @timeout_unless_slower(6)
     @pytest.mark.filterwarnings("ignore:Truncated File Read")
     def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
         with Image.open("Tests/images/timeout-6646305047838720") as im:
@@ -1028,7 +1028,7 @@ def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
             "Tests/images/oom-225817ca0f8c663be7ab4b9e717b02c661e66834.tif",
         ],
     )
-    @timeout_unless_slower_valgrind(2)
+    @timeout_unless_slower(2)
     def test_oom(self, test_file: str) -> None:
         with pytest.raises(UnidentifiedImageError):
             with pytest.warns(UserWarning, match="Corrupt EXIF data"):
diff --git a/Tests/test_image.py b/Tests/test_image.py
index cc6e4d137ca..b052bdf6769 100644
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -34,7 +34,7 @@
     is_win32,
     mark_if_feature_version,
     skip_unless_feature,
-    timeout_unless_slower_valgrind,
+    timeout_unless_slower,
 )
 
 ElementTree: ModuleType | None
@@ -580,7 +580,7 @@ def test_check_size(self) -> None:
         i = Image.new("RGB", [1, 1])
         assert isinstance(i.size, tuple)
 
-    @timeout_unless_slower_valgrind(0.75)
+    @timeout_unless_slower(0.75)
     @pytest.mark.parametrize("size", ((0, 100000000), (100000000, 0)))
     def test_empty_image(self, size: tuple[int, int]) -> None:
         Image.new("RGB", size)
diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py
index 883df051d1e..18b7196058a 100644
--- a/Tests/test_imagefontpil.py
+++ b/Tests/test_imagefontpil.py
@@ -7,7 +7,7 @@
 
 from PIL import Image, ImageDraw, ImageFont, _util, features
 
-from .helper import assert_image_equal_tofile, timeout_unless_slower_valgrind
+from .helper import assert_image_equal_tofile, timeout_unless_slower
 
 fonts = [ImageFont.load_default_imagefont()]
 if not features.check_module("freetype2"):
@@ -78,7 +78,7 @@ def test_decompression_bomb() -> None:
         font.getmask("A" * 1_000_000)
 
 
-@timeout_unless_slower_valgrind(4)
+@timeout_unless_slower(4)
 def test_oom() -> None:
     glyph = struct.pack(
         ">hhhhhhhhhh", 1, 0, -32767, -32767, 32767, 32767, -32767, -32767, 32767, 32767
diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py
index 1d2fae1a6fa..c6702fdd30d 100644
--- a/Tests/test_imagemorph.py
+++ b/Tests/test_imagemorph.py
@@ -7,7 +7,7 @@
 
 from PIL import Image, ImageMorph, _imagingmorph
 
-from .helper import assert_image_equal_tofile, hopper, timeout_unless_slower_valgrind
+from .helper import assert_image_equal_tofile, hopper, timeout_unless_slower
 
 
 def string_to_img(image_string: str) -> Image.Image:
@@ -266,7 +266,7 @@ def test_unknown_pattern() -> None:
 @pytest.mark.parametrize(
     "pattern", ("a pattern with a syntax error", "4:(" + "X" * 30000)
 )
-@timeout_unless_slower_valgrind(1)
+@timeout_unless_slower(1)
 def test_pattern_syntax_error(pattern: str) -> None:
     # Arrange
     lb = ImageMorph.LutBuilder(op_name="corner")
