diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a2c30fa07329cac058b8fc76d895e0b487f675cf..93051d3ed8d4cebc4457613ff6be7855142b764e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,6 +1,24 @@
 # vim: ts=2
 
+stages:
+  - testing
+  - release
+
+run-tests:
+  stage: testing
+  script:
+    - rm -rf .venv uv.lock
+    - uv sync --all-extras --no-progress
+    - source .venv/bin/activate
+    - ./run-tests.sh
+    - deactivate
+  coverage: /TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/
+  rules:
+    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
+    - if: $CI_COMMIT_TAG =~ /^v\d+/
+
 sonarqube-check:
+  stage: testing
   variables:
     SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"  # Defines the location of the analysis task cache
     GIT_DEPTH: "0"  # Tells git to fetch all the branches of the project, required by the analysis task
@@ -16,12 +34,12 @@ sonarqube-check:
     - merge_requests
 
 pypi-release:
+  stage: release
   rules:
     - if: '$CI_COMMIT_TAG =~ /^v\d+/'
   script:
-    - pip3 install --upgrade pip build twine check-manifest
     - rm -f dist/*
-    - python3 -m check_manifest
-    - python3 -m build
-    - python3 -m twine check dist/*
-    - TWINE_USERNAME=${PYPI_USER} TWINE_PASSWORD=${PYPI_PASSWORD} python3 -m twine upload --skip-existing --non-interactive dist/*
+    - uvx check-manifest
+    - uv build
+    - uvx twine check dist/*
+    - TWINE_USERNAME=${PYPI_USER} TWINE_PASSWORD=${PYPI_PASSWORD} uvx twine upload --skip-existing --non-interactive dist/*