Repository: doodspav/patomic (language: python)

Issue:
Setup pipeline and presets
- create CMake presets
  - support code coverage
  - support -fsanitize
- run tests automatically
  - on all pushes to develop/main and all PRs
  - on all possible architectures
  - as many warnings enabled as possible (as errors)
  - generated code coverage, combine coverage from all tests, and fail under a certain % (also output statistic as a comment if possible)
  - merging a release branch into main should create a release and tag it
  - ~~maybe generate docs?~~ do this when setting up docs
- after:
  - require that all tests actually passed (in a separate stage)
  - require status checks to pass before merging on some branches
  - require deployments to succeed before merging on some branches


Discussion / hints:

Fix patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..b998ee120
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,1 @@
+*   @doodspav
diff --git a/.github/workflows/check-todo-fixme.yml b/.github/workflows/check-todo-fixme.yml
new file mode 100644
index 000000000..efa004361
--- /dev/null
+++ b/.github/workflows/check-todo-fixme.yml
@@ -0,0 +1,153 @@
+name: Check todo and fixme
+
+on:
+  pull_request:
+    branches:
+      - '**'
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout patomic
+        uses: actions/checkout@v4
+        with:
+          path: patomic
+
+      - name: Get Open Issues
+        id: get-open-issues
+        uses: actions/github-script@v7
+        with:
+          script: |
+            // get repository details
+            const [owner, repo] = "${{ github.repository }}".split("/");
+            
+            // make graphql query
+            const graphql = `query listOpenIssues($repository: String!, $owner: String!, $cursor: String) {
+              repository(name: $repository, owner: $owner) {
+                issues(first: 100, states: OPEN, after: $cursor) {
+                  nodes {
+                    number
+                  }
+                  pageInfo {
+                    endCursor
+                    hasNextPage
+                  }
+                }
+              }
+            }`;
+            
+            // get information from graphql api
+            let cursor;
+            let openIssueNumbers = [];
+            do {
+              const response = await github.graphql(graphql, { repository: repo, owner: owner, cursor });
+              response.repository.issues.nodes.forEach(({ number }) => openIssueNumbers.push(number));
+              cursor = response.repository.issues.pageInfo.endCursor;
+            } while (cursor);
+            
+            // save data as json by returning
+            console.log(openIssueNumbers);
+            return openIssueNumbers;
+
+      - name: Scan Files for Fixme and Todo
+        id: scan-files
+        env:
+          BASE_PATH: './patomic'
+        uses: actions/github-script@v7
+        with:
+          script: |
+            // dependencies
+            const fs = require('fs');
+            const path = require('path');
+            
+            // get issue id if line contains todo or fixme
+            // return: null | { issueId: null } | { issueId: 'number' }
+            function parseLine(line) {
+              const regex = /(fixme|todo)/i;
+              const issueIdRegex = /ghi\s#(\d+)/i;
+              if (regex.test(line)) {
+                const match = line.match(issueIdRegex);
+                const issueId = match ? match[1] : null;
+                return { issueId };
+              } else {
+                return null;
+              }
+            }
+            
+            // list all files in directory (except excluded ones)
+            // return: [ path1, path2, ... ]
+            const excludedDirs = ['.git'];
+            const excludedFiles = ['check-todo-fixme.yml'];
+            function listFiles(dir) {
+              let result = [];
+              fs.readdirSync(dir).forEach(file => {
+                // base set up
+                let fullPath = path.join(dir, file);
+                let stat = fs.statSync(fullPath);
+                // path is a directory
+                if (stat && stat.isDirectory()) {
+                  if (!excludedDirs.includes(file)) {
+                    result = result.concat(listFiles(fullPath));
+                  }
+                // path is a file
+                } else if (!excludedFiles.includes(file)) {
+                  result.push(fullPath)
+                }
+              });
+              return result;
+            }
+            
+            // parse all lines in all files
+            // return: [ { filePath: <path>, lineNum: <num>, issueId: <id> }, ... ]
+            function parseAll(dir) {
+              const files = listFiles(dir);
+              const items = []
+              files.forEach(file => {
+                const content = fs.readFileSync(file, 'utf-8');
+                const lines = content.split('\n');
+                lines.forEach((line, index) => {
+                  const item = parseLine(line);
+                  if (item) {
+                    item.filePath = file;
+                    item.lineNum = index + 1;
+                    items.push(item);
+                  }
+                });
+              });
+              return items;
+            }
+            
+            // save data as json by returning
+            const ret = parseAll('${{ env.BASE_PATH }}');
+            console.log(ret);
+            return ret;
+
+      - name: Check Issues
+        uses: actions/github-script@v7
+        with:
+          script: |
+            // get outputs
+            const openIssueIds = ${{ steps.get-open-issues.outputs.result }};
+            const parsedItems = ${{ steps.scan-files.outputs.result }};
+
+            // go through all parsed items
+            let errorCount = 0;
+            parsedItems.forEach(item => {
+              const filePath = item.filePath.replace('patomic/', '');
+              if (item.issueId === null) {
+                ++errorCount;
+                const msg = "No GitHub issue mentioned in todo/fixme comment (must match pattern '/ghi\\s#(\\d+)/i').";
+                console.error(`::error file=${filePath},line=${item.lineNum}::${msg}`);
+              } else if (!openIssueIds.includes(parseInt(item.issueId))) {
+                ++errorCount;
+                const msg = `GitHub issue #${item.issueId} does not correspond to an open issue number for GitHub repository ${{ github.repository }} (maybe it was resolved and closed).`
+                console.error(`::error file=${filePath},line=${item.lineNum}::${msg}`);
+              }
+            });
+
+            // fail if error was printed
+            if (errorCount > 0) {
+              process.exit(1);
+            }
diff --git a/.gitignore b/.gitignore
index c6127b38c..413d6cf4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,6 @@ modules.order
 Module.symvers
 Mkfile.old
 dkms.conf
+
+# CMake
+CMakeUserPresets.json
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f284e6903..84ac005cf 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -44,7 +44,7 @@ target_sources(
 add_subdirectory(src)
 
 
-# ---- Generate Build Info Headers ----
+# ---- Generate Export Headers ----
 
 # used in export header generated below
 if(NOT PATOMIC_BUILD_SHARED)
@@ -102,6 +102,7 @@ endif()
 if(PATOMIC_BUILD_TESTING)
 
     # need to enable testing in case BUILD_TESTING is disabled
+    # CTest expects that the top level project enables testing
     if(PROJECT_IS_TOP_LEVEL)
         enable_testing()
     endif()
diff --git a/CMakePresets.json b/CMakePresets.json
new file mode 100644
index 000000000..bdea28a21
--- /dev/null
+++ b/CMakePresets.json
@@ -0,0 +1,261 @@
+{
+  "version": 3,
+  "configurePresets": [
+    {
+      "name": "_patomic-ci-base",
+      "hidden": true,
+      "binaryDir": "${sourceDir}/build",
+      "cacheVariables": {
+        "BUILD_TESTING": true,
+        "CMAKE_C_STANDARD": "11",
+        "CMAKE_C_STANDARD_REQUIRED": true,
+        "CMAKE_C_EXTENSIONS": false,
+        "CMAKE_CXX_STANDARD": "14",
+        "CMAKE_CXX_STANDARD_REQUIRED": true,
+        "CMAKE_CXX_EXTENSIONS": false
+      }
+    },
+    {
+      "name": "_patomic-ci-qemu-base",
+      "hidden": true,
+      "toolchainFile": "ci/toolchain/qemu-linux-gnu.cmake",
+      "inherits": [
+        "_patomic-ci-base"
+      ]
+    },
+
+    {
+      "name": "_patomic-ci-native-compiler-clang",
+      "hidden": true,
+      "generator": "Unix Makefiles",
+      "cacheVariables": {
+        "CMAKE_C_COMPILER": "clang",
+        "CMAKE_CXX_COMPILER": "clang++"
+      }
+    },
+    {
+      "name": "_patomic-ci-native-compiler-gcc",
+      "hidden": true,
+      "generator": "Unix Makefiles",
+      "cacheVariables": {
+        "CMAKE_C_COMPILER": "gcc",
+        "CMAKE_CXX_COMPILER": "g++"
+      }
+    },
+    {
+      "name": "_patomic-ci-native-compiler-msvc",
+      "hidden": true,
+      "generator": "Visual Studio 17 2022",
+      "cacheVariables": {
+        "CMAKE_C_COMPILER": "cl",
+        "CMAKE_CXX_COMPILER": "cl"
+      }
+    },
+
+    {
+      "name": "_patomic-ci-flags-ansi-gnu",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS": "-Wall -Wextra -Werror -Wpedantic -Wno-unused-function -Wno-atomic-alignment",
+        "CMAKE_C_STANDARD": "90"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-coverage-clang",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "-fprofile-instr-generate -fcoverage-mapping",
+        "CMAKE_CXX_FLAGS_INIT": "-fprofile-instr-generate -fcoverage-mapping",
+        "CMAKE_EXE_LINKER_FLAGS_INIT": "-fprofile-instr-generate -fcoverage-mapping",
+        "CMAKE_SHARED_LINKER_FLAGS_INIT": "-fprofile-instr-generate -fcoverage-mapping"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-coverage-gcc",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "--coverage",
+        "CMAKE_CXX_FLAGS_INIT": "--coverage",
+        "CMAKE_EXE_LINKER_FLAGS_INIT": "--coverage",
+        "CMAKE_SHARED_LINKER_FLAGS_INIT": "--coverage"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-sanitize-gnu",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "-fsanitize=address,undefined -fsanitize-recover=all -fno-omit-frame-pointer",
+        "CMAKE_CXX_FLAGS_INIT": "-fsanitize=address,undefined -fsanitize-recover=all -fno-omit-frame-pointer '-DPATOMIC_HAS_ASAN=1' '-DPATOMIC_HAS_UBSAN=1'",
+        "CMAKE_EXE_LINKER_FLAGS_INIT": "-fsanitize=address,undefined -fsanitize-recover=all",
+        "CMAKE_SHARED_LINKER_FLAGS_INIT": "-fsanitize=address,undefined -fsanitize-recover=all"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-warning-clang",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "-Weverything -Werror -Wpedantic -Wno-c++98-compat -Wno-covered-switch-default -Wno-padded -Wno-unused-function -Wno-atomic-alignment -Wno-poison-system-directories",
+        "CMAKE_CXX_FLAGS_INIT": "-Wall -Wextra -Werror -Wpedantic"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-warning-gcc",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "-Wall -Wextra -Werror -Wpedantic -Wshadow -Wcast-align -Wconversion -Wsign-conversion -Wnull-dereference -Wdouble-promotion -Wstrict-prototypes -Wmisleading-indentation -Wduplicated-branches -Wlogical-op -Wdeclaration-after-statement -Wno-unused-function",
+        "CMAKE_CXX_FLAGS_INIT": "-Wall -Wextra -Werror -Wpedantic"
+      }
+    },
+    {
+      "name": "_patomic-ci-flags-warning-msvc",
+      "hidden": true,
+      "cacheVariables": {
+        "CMAKE_C_FLAGS_INIT": "/permissive- /volatile:iso /Wall /WX /wd4464 /wd4132 /wd4820 /wd4127 /wd5045 /wd4710 /wd4711 /wd4668",
+        "CMAKE_CXX_FLAGS_INIT": "/permissive- /volatile:iso /W4 /WX"
+      }
+    },
+
+    {
+      "name": "patomic-ci-native-win32-msvc-warning",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-msvc",
+        "_patomic-ci-flags-warning-msvc"
+      ],
+      "architecture": "Win32"
+    },
+    {
+      "name": "patomic-ci-native-win64-msvc-warning",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-msvc",
+        "_patomic-ci-flags-warning-msvc"
+      ],
+      "architecture": "x64"
+    },
+
+    {
+      "name": "patomic-ci-native-unix-clang-ansi",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-clang",
+        "_patomic-ci-flags-ansi-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-clang-coverage",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-clang",
+        "_patomic-ci-flags-coverage-clang"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-clang-sanitize",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-clang",
+        "_patomic-ci-flags-sanitize-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-clang-warning",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-clang",
+        "_patomic-ci-flags-warning-clang"
+      ]
+    },
+
+    {
+      "name": "patomic-ci-native-unix-gcc-ansi",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-gcc",
+        "_patomic-ci-flags-ansi-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-gcc-coverage",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-gcc",
+        "_patomic-ci-flags-coverage-gcc"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-gcc-sanitize",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-gcc",
+        "_patomic-ci-flags-sanitize-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-native-unix-gcc-warning",
+      "inherits": [
+        "_patomic-ci-base",
+        "_patomic-ci-native-compiler-gcc",
+        "_patomic-ci-flags-warning-gcc"
+      ]
+    },
+
+    {
+      "name": "patomic-ci-qemu-ubuntu-clang-ansi",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-ansi-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-clang-coverage",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-coverage-clang"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-clang-sanitize",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-sanitize-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-clang-warning",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-warning-clang"
+      ]
+    },
+
+    {
+      "name": "patomic-ci-qemu-ubuntu-gcc-ansi",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-ansi-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-gcc-coverage",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-coverage-gcc"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-gcc-sanitize",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-sanitize-gnu"
+      ]
+    },
+    {
+      "name": "patomic-ci-qemu-ubuntu-gcc-warning",
+      "inherits": [
+        "_patomic-ci-qemu-base",
+        "_patomic-ci-flags-warning-gcc"
+      ]
+    }
+  ]
+}
diff --git a/ci/toolchain/qemu-linux-gnu.cmake b/ci/toolchain/qemu-linux-gnu.cmake
new file mode 100644
index 000000000..9c09f9134
--- /dev/null
+++ b/ci/toolchain/qemu-linux-gnu.cmake
@@ -0,0 +1,123 @@
+# ---- Options Summary ----
+
+# The follow options MUST ALL be set, either as cache or environment variables.
+
+# -------------------------------------------------------------------------------------------------------------
+# | Option                       | Docs                                                                       |
+# |==============================|============================================================================|
+# | PATOMIC_CI_SYSROOT           | sysroot directory path, passed on to CMake                                 |
+# | PATOMIC_CI_XARCH             | target architecture, used to select qemu architecture, e.g. aarch64        |
+# | PATOMIC_CI_XCOMPILER         | host cross-compiler, supported values are 'gcc' and 'clang'                |
+# | PATOMIC_CI_XCOMPILER_VERSION | host cross-compiler version, major version of the compiler being used      |
+# | PATOMIC_CI_XTRIPLE           | target triple, used to set compiler and its target, e.g. aarch64-linux-gnu |
+# -------------------------------------------------------------------------------------------------------------
+
+
+# ---- Get Cache/Environment Variables ----
+
+# target triple, used to set compiler and its target, e.g. aarch64-linux-gnu
+set(
+    PATOMIC_CI_XTRIPLE "$ENV{PATOMIC_CI_XTRIPLE}" CACHE STRING
+    "target triple, used to set compiler and its target, e.g. aarch64-linux-gnu"
+)
+if(NOT PATOMIC_CI_XTRIPLE)
+    message(FATAL_ERROR "PATOMIC_CI_XTRIPLE cache/environment variable is not set")
+elseif(NOT PATOMIC_CI_XTRIPLE MATCHES "^([a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)?)$")
+    message(FATAL_ERROR "PATOMIC_CI_XTRIPLE value '${PATOMIC_CI_XTRIPLE}' does not match expected pattern '<arch>-<vendor?>-<os>-<abi>'")
+endif()
+
+# target architecture, used to select qemu architecture, e.g. aarch64
+set(
+    PATOMIC_CI_XARCH "$ENV{PATOMIC_CI_XARCH}" CACHE STRING
+    "target architecture, used to select qemu architecture, e.g. aarch64"
+)
+if(NOT PATOMIC_CI_XARCH)
+    message(FATAL_ERROR "PATOMIC_CI_XARCH cache/environment variable is not set")
+endif()
+
+# host cross-compiler, supported values are 'gcc' and 'clang'
+set(
+    PATOMIC_CI_XCOMPILER "$ENV{PATOMIC_CI_XCOMPILER}" CACHE STRING
+    "host cross-compiler, supported values are 'gcc' and 'clang'"
+)
+if(NOT PATOMIC_CI_XCOMPILER)
+    message(FATAL_ERROR "PATOMIC_CI_XCOMPILER cache/environment variable is not set")
+elseif(NOT PATOMIC_CI_XCOMPILER MATCHES "^(clang|gcc)$")
+    message(FATAL_ERROR "PATOMIC_CI_XCOMPILER value '${PATOMIC_CI_XCOMPILER}' is not 'clang' or 'gcc'")
+endif()
+
+# host cross-compiler version, major version of the compiler being used
+set(
+    PATOMIC_CI_XCOMPILER_VERSION "$ENV{PATOMIC_CI_XCOMPILER_VERSION}" CACHE STRING
+    "host cross-compiler version, major version of the compiler being used"
+)
+if(NOT PATOMIC_CI_XCOMPILER_VERSION)
+    message(FATAL_ERROR "PATOMIC_CI_XCOMPILER_VERSION cache/environment variable is not set")
+elseif(NOT PATOMIC_CI_XCOMPILER_VERSION MATCHES "^([0-9]+)$")
+    message(FATAL_ERROR "PATOMIC_CI_XCOMPILER_VERSION '${PATOMIC_CI_XCOMPILER_VERSION}' does not match regex '[0-9]+'")
+endif()
+
+# sysroot directory path, passed on to CMake
+set(
+    PATOMIC_CI_SYSROOT "$ENV{PATOMIC_CI_SYSROOT}" CACHE FILEPATH
+    "sysroot directory path, passed on to CMake"
+)
+if(NOT PATOMIC_CI_SYSROOT)
+    message(FATAL_ERROR "PATOMIC_CI_SYSROOT cache/environment variable is not set")
+endif()
+
+
+# ---- Force Use of Cache Variables ----
+
+set(cache_variables )
+list(APPEND cache_variables
+    "PATOMIC_CI_SYSROOT"
+    "PATOMIC_CI_XARCH"
+    "PATOMIC_CI_XCOMPILER"
+    "PATOMIC_CI_XCOMPILER_VERSION"
+    "PATOMIC_CI_XTRIPLE"
+)
+
+# tells CMake to pass these variables when try_compile is invoked (runs toolchain file)
+list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES "${cache_variables}")
+list(REMOVE_DUPLICATES CMAKE_TRY_COMPILE_PLATFORM_VARIABLES)
+
+
+# ---- Set CMake Variables ----
+
+# set basic target information
+set(CMAKE_SYSTEM_NAME Linux)
+set(CMAKE_SYSTEM_PROCESSOR "${PATOMIC_CI_XARCH}")
+
+# use sysroot at compile time and pass to QEMU when executing target binaries
+set(qemu_command "qemu-${PATOMIC_CI_XARCH}")
+if(qemu_command STREQUAL "qemu-armhf")
+    set(qemu_command "qemu-arm")
+elseif(qemu_command STREQUAL "qemu-x86_32")
+    set(qemu_command "qemu-i386")
+endif()
+set(CMAKE_SYSROOT "${PATOMIC_CI_SYSROOT}")
+set(CMAKE_CROSSCOMPILING_EMULATOR "${qemu_command};-L;${PATOMIC_CI_SYSROOT}")
+
+# set the appropriate cross compilers and archiver
+if(PATOMIC_CI_XCOMPILER STREQUAL "clang")
+    set(CMAKE_C_COMPILER "clang-${PATOMIC_CI_XCOMPILER_VERSION}")
+    set(CMAKE_CXX_COMPILER "clang++-${PATOMIC_CI_XCOMPILER_VERSION}")
+    set(CMAKE_AR "llvm-ar-${PATOMIC_CI_XCOMPILER_VERSION}")
+elseif(PATOMIC_CI_XCOMPILER STREQUAL "gcc")
+    set(CMAKE_C_COMPILER "${PATOMIC_CI_XTRIPLE}-gcc-${PATOMIC_CI_XCOMPILER_VERSION}")
+    set(CMAKE_CXX_COMPILER "${PATOMIC_CI_XTRIPLE}-g++-${PATOMIC_CI_XCOMPILER_VERSION}")
+    set(CMAKE_AR "${PATOMIC_CI_XTRIPLE}-ar")
+endif()
+
+# set the target triple matching the cross compilers above
+set(CMAKE_C_COMPILER_TARGET "${PATOMIC_CI_XTRIPLE}")
+set(CMAKE_CXX_COMPILER_TARGET "${PATOMIC_CI_XTRIPLE}")
+
+# search for programs in the host environment
+set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+
+# search for libraries, headers, and packages in the target environment
+set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
diff --git a/cmake/OptionVariables.cmake b/cmake/OptionVariables.cmake
index a7480567a..3f5c61e14 100644
--- a/cmake/OptionVariables.cmake
+++ b/cmake/OptionVariables.cmake
@@ -4,18 +4,18 @@
 
 # ---- Options Summary ----
 
-# ------------------------------------------------------------------------------------------------
-# | Option                       | Availability  | Default                                       |
-# |==============================|===============|===============================================|
-# | BUILD_SHARED_LIBS            | Top-Level     | OFF                                           |
-# | BUILD_TESTING                | Top-Level     | OFF                                           |
-# | CMAKE_INSTALL_INCLUDEDIR     | Top-Level     | include/${package_name}-${PROJECT_VERSION}    |
-# |------------------------------|---------------|-----------------------------------------------|
-# | PATOMIC_BUILD_SHARED         | Always        | ${BUILD_SHARED_LIBS}                          |
-# | PATOMIC_BUILD_TESTING        | Always        | ${BUILD_TESTING}                              |
-# | PATOMIC_INCLUDES_WITH_SYSTEM | Not Top-Level | ON                                            |
-# | PATOMIC_INSTALL_CMAKEDIR     | Always        | ${CMAKE_INSTALL_LIBDIR}/cmake/${package_name} |
-# ------------------------------------------------------------------------------------------------
+# -------------------------------------------------------------------------------------------------------------------
+# | Option                       | Availability  | Default                                                          |
+# |==============================|===============|==================================================================|
+# | BUILD_SHARED_LIBS            | Top-Level     | OFF                                                              |
+# | BUILD_TESTING                | Top-Level     | OFF                                                              |
+# | CMAKE_INSTALL_INCLUDEDIR     | Top-Level     | include/${package_name}-${PROJECT_VERSION}                       |
+# |------------------------------|---------------|------------------------------------------------------------------|
+# | PATOMIC_BUILD_SHARED         | Always        | ${BUILD_SHARED_LIBS}                                             |
+# | PATOMIC_BUILD_TESTING        | Always        | ${BUILD_TESTING} AND ${PROJECT_IS_TOP_LEVEL}                     |
+# | PATOMIC_INCLUDES_WITH_SYSTEM | Not Top-Level | ON                                                               |
+# | PATOMIC_INSTALL_CMAKEDIR     | Always        | ${CMAKE_INSTALL_LIBDIR}/cmake/${package_name}-${PROJECT_VERSION} |
+# -------------------------------------------------------------------------------------------------------------------
 
 
 # ---- Build Shared ----
@@ -66,20 +66,26 @@ endif()
 if(PROJECT_IS_TOP_LEVEL)
     option(BUILD_TESTING "Build tests" OFF)
 endif()
+if(PROJECT_IS_TOP_LEVEL AND BUILD_TESTING)
+    set(build_testing ON)
+endif()
 option(
     PATOMIC_BUILD_TESTING
     "Override BUILD_TESTING for ${package_name} library"
-    ${BUILD_TESTING}
+    ${build_testing}
 )
+set(build_testing )
 mark_as_advanced(PATOMIC_BUILD_TESTING)
 
 
 # ---- Install Include Directory ----
 
 # Adds an extra directory to the include path by default, so that when you link
-# against the target, you get `<prefix>/include/<package>-X.Y.Z` added to your
+# against the target, you get `<prefix>/include/<package>-X` added to your
 # include paths rather than `<prefix/include`.
-# This doesn't affect include paths used by consumers of this project.
+# This doesn't affect include paths used by consumers of this project, but helps
+# prevent consumers having access to other projects in the same include
+# directory (e.g. /usr/include).
 # The variable type is STRING rather than PATH, because otherwise passing
 # -DCMAKE_INSTALL_INCLUDEDIR=include on the command line would expand to an
 # absolute path with the base being the current CMake directory, leading to
@@ -95,7 +101,7 @@ endif()
 
 
 # do not include earlier or we can't set CMAKE_INSTALL_INCLUDEDIR above
-# required for CMAKE_INSTALL_LIBDIR below
+# include required for CMAKE_INSTALL_LIBDIR below
 include(GNUInstallDirs)
 
 
@@ -103,14 +109,20 @@ include(GNUInstallDirs)
 
 # This allows package maintainers to freely override the installation path for
 # the CMake configs.
+# The path is versioned so that multiple installed versions can coexist together
+# and uses the full version to aid in debugging.
 # This doesn't affect include paths used by consumers of this project.
 # The variable type is STRING rather than PATH, because otherwise passing
 # -DPATOMIC_INSTALL_CMAKEDIR=lib/cmake on the command line would expand to an
 # absolute path with the base being the current CMake directory, leading to
 # unexpected errors.
+#
+# Note: this will yield the lowest compatible version on platforms that sort
+# their directories' contents unless you set CMAKE_FIND_PACKAGE_SORT_DIRECTION
+# to DEC before calling find_package.
 set(
-    PATOMIC_INSTALL_CMAKEDIR "${CMAKE_INSTALL_LIBDIR}/cmake/${package_name}"
-    CACHE STRING "CMake package config location relative to the install prefix"
+    PATOMIC_INSTALL_CMAKEDIR "${CMAKE_INSTALL_LIBDIR}/cmake/${package_name}-${PROJECT_VERSION}"
+    CACHE STRING "CMake ${package_name} package's config location relative to the install prefix"
 )
 # depends on CMAKE_INSTALL_LIBDIR which is marked as advanced in GNUInstallDirs
 mark_as_advanced(PATOMIC_INSTALL_CMAKEDIR)


Test patch:
diff --git a/.github/workflows/_reusable-test-native.yml b/.github/workflows/_reusable-test-native.yml
new file mode 100644
index 000000000..1bd95a31d
--- /dev/null
+++ b/.github/workflows/_reusable-test-native.yml
@@ -0,0 +1,251 @@
+name: Run Tests on Native Platforms
+
+on:
+  workflow_call:
+    inputs:
+      os:
+        description: 'Used to set the runs-on attribute, must be one of macos, ubuntu, windows'
+        required: true
+        type: string
+        default: ''
+      preset:
+        description: 'CMake preset used to build patomic and googletest, will be conditionally suffixed with one of ansi, coverage, sanitize, warning'
+        required: true
+        type: string
+        default: ''
+      architecture:
+        description: 'CPU architecture tests are run on, used for naming (does not affect how tests are run)'
+        required: true
+        type: string
+        default: ''
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    
+    steps:
+      - name: Check No Empty Inputs
+        shell: bash
+        run: |
+          # checks input is not empty
+          check_input() {
+            input_name="${1}"
+            input_value="${2}"
+            if [[ -z "${input_value}" ]]; then
+              echo "Error: required input '${input_name}' is empty or was not specified."
+              exit 1
+            else
+              echo "${input_name}: ${input_value}"
+            fi
+          }
+
+          # apply function to all inputs
+          check_input "os"           "${{ inputs.os }}"
+          check_input "preset"       "${{ inputs.preset }}"
+          check_input "architecture" "${{ inputs.architecture }}"
+
+  run:
+    needs: check
+    runs-on: ${{ inputs.os }}-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        kind:
+          - ansi
+          - coverage
+          - sanitize
+          - warning
+        build_shared:
+          - static
+          - shared
+        include:
+          # hidden single variable
+          - compiler: ${{ contains(inputs.preset, 'clang') && 'clang' || ( contains(inputs.preset, 'gcc') && 'gcc' || 'msvc' ) }}
+          # alias variables
+          - build_shared: static
+            cmake_build_shared: OFF
+          - build_shared: shared
+            cmake_build_shared: ON
+    env:
+      # these have to be in env context because they depend on matrix context
+      UNIQUE_ID: ${{ matrix.kind }}-${{ inputs.architecture }}-${{ inputs.os }}-${{ matrix.compiler }}-${{ matrix.build_shared }}
+      UNIQUE_NAME: ${{ inputs.architecture }}-${{ inputs.os }}-${{ matrix.compiler }}-${{ matrix.build_shared }}
+      CMAKE_PRESET: ${{ inputs.preset }}-${{ matrix.kind }}
+      CMAKE_BUILD_TYPE: ${{ ( matrix.kind == 'ansi' || matrix.kind == 'warning' ) && 'Release' || 'Debug' }}
+      # currently not possible to skip job using matrix context, so this is the next best thing
+      SKIP_JOB: >-
+        ${{ (
+          ( matrix.kind == 'ansi' && matrix.compiler == 'msvc' )  ||
+          ( matrix.kind == 'coverage' && inputs.os == 'windows' ) ||
+          ( matrix.kind == 'sanitize' && inputs.os == 'windows' )
+        ) }}
+      # this job supports multiple os, each with their own root path
+      # this screws up lcov when it tries to merge the coverage path mappings
+      # we can't just modify mappings because:
+      # - GitHub issue #65006 on llvm/llvm-project suggests to NOT use -fcoverage-prefix-map
+      # - macos runner has AppleClang 14 which doesn't support -fcoverage-compilation-dir
+      # workaround is to have a fixed root path that everyone works from
+      # use /Users as root dir because /home is not supported on macos
+      ROOT_PATH: /Users/${{ matrix.kind }}
+
+    steps:
+      - name: Set Up Root Path
+        if: env.SKIP_JOB != 'true'
+        shell: bash
+        run: |
+          # macos and ubuntu need sudo, windows does not
+          prefix=""
+          if [[ "${{ inputs.os }}" != "windows" ]]; then
+            prefix="sudo "
+          fi
+          
+          ${prefix}mkdir -p "${{ env.ROOT_PATH }}"
+          ${prefix}chmod -R a+rwx "${{ env.ROOT_PATH }}"
+
+      - name: Checkout patomic
+        if: env.SKIP_JOB != 'true'
+        uses: actions/checkout@v4
+        with:
+          # cannot prefix with ROOT_PATH here, so copy over in next step
+          path: patomic
+
+      - name: Move patomic to Root Path Location
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cp -R ./patomic/ "${{ env.ROOT_PATH }}/patomic"
+
+      - name: Restore Cached GoogleTest
+        if: env.SKIP_JOB != 'true'
+        id: cache-googletest
+        uses: actions/cache@v3
+        with:
+          path: ${{ env.ROOT_PATH }}/googletest/build/install
+          key: googletest-${{ env.UNIQUE_ID }}
+
+      - name: Checkout GoogleTest
+        if: env.SKIP_JOB != 'true' && steps.cache-googletest.outputs.cache-hit != 'true'
+        uses: actions/checkout@v4
+        with:
+          repository: google/googletest
+          # cannot prefix with ROOT_PATH here, so copy over in next step
+          path: googletest
+
+      - name: Move GoogleTest to Root Path Location
+        if: env.SKIP_JOB != 'true' && steps.cache-googletest.outputs.cache-hit != 'true'
+        run: |
+          cp -R ./googletest/ "${{ env.ROOT_PATH }}/googletest"
+
+      - name: Build and Install GoogleTest
+        if: env.SKIP_JOB != 'true' && steps.cache-googletest.outputs.cache-hit != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd googletest
+          cp ../patomic/CMakePresets.json .
+          mkdir build
+          cd build
+          cmake --preset ${{ env.CMAKE_PRESET }} -DBUILD_TESTING=OFF -DCMAKE_CXX_FLAGS="" -DBUILD_SHARED_LIBS=${{ matrix.cmake_build_shared }} -DCMAKE_BUILD_TYPE=${{ env.CMAKE_BUILD_TYPE }} -Dgtest_force_shared_crt=ON -Dgtest_hide_internal_symbols=ON ..
+          cmake --build . --verbose --config ${{ env.CMAKE_BUILD_TYPE }}
+          cmake --install . --config ${{ env.CMAKE_BUILD_TYPE }} --prefix install
+
+      # TODO: figure out how to cache this for builds where we don't change any source code (GHI #31)
+      - name: Build patomic
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd patomic
+          mkdir build
+          cd build
+          cmake --preset ${{ env.CMAKE_PRESET }} -DBUILD_SHARED_LIBS=${{ matrix.cmake_build_shared }} -DCMAKE_BUILD_TYPE=${{ env.CMAKE_BUILD_TYPE }} -DGTest_ROOT:PATH="../../googletest/build/install" ..
+          cmake --build . --verbose --config ${{ env.CMAKE_BUILD_TYPE }}
+      
+      - name: Test patomic
+        if: env.SKIP_JOB != 'true'
+        continue-on-error: true
+        env:
+          LABEL_REGEX: ${{ matrix.kind == 'coverage' && '^(ut)$' || '^(.*)$' }}
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd patomic/build
+          ctest --label-regex "${{ env.LABEL_REGEX }}" --verbose --output-junit Testing/Temporary/results.xml --build-config ${{ env.CMAKE_BUILD_TYPE }} .
+
+      - name: Prepare Test Results
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          mkdir -p upload/test/${{ matrix.kind }}
+          python3 patomic/test/improve_ctest_xml.py --input patomic/build/Testing/Temporary/results.xml --triple ${{ matrix.kind }}-${{ env.UNIQUE_NAME }} --output upload/test/${{ matrix.kind }}/${{ env.UNIQUE_NAME }}.xml
+
+      - name: Upload Test Results
+        if: env.SKIP_JOB != 'true'
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-results
+          path: ${{ env.ROOT_PATH }}/upload/test/
+
+      - name: Generate Lcov Tracefile and Root Path File (clang)
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage' && matrix.compiler == 'clang'
+        shell: bash
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          # set up directory
+          mkdir -p upload/cov/${{ env.UNIQUE_NAME }}
+          cd patomic/build
+          
+          # macos needs xcrun to help use xcode to run llvm tools
+          # ubuntu needs llvm tools to be installed
+          prefix=""
+          if [[ "${{ inputs.os }}" == "macos" ]]; then
+            prefix="xcrun "
+          else  # [[ "${{ inputs.os }}" == "ubuntu" ]]; then
+            sudo apt install llvm
+          fi
+
+          # merge coverage output from all tests
+          # use find because bash on GitHub Actions currently does not support '**'
+          find test/working -type f -name "*.profraw" -print0 | xargs -0 ${prefix}llvm-profdata merge -output=patomic.profdata
+          
+          # convert profdata to lcov tracefile, and copy to upload
+          lib_files=(libpatomic.*)  # matches shared/static lib files and symlinks
+          ${prefix}llvm-cov export -format=lcov -instr-profile=patomic.profdata -object="${lib_files[0]}" >> patomic.lcov
+          cp patomic.lcov ../../upload/cov/${{ env.UNIQUE_NAME }}/patomic.lcov
+          
+          # we need original source mapping to make use of lcov tracefile
+          # we can't just modify mapping because:
+          # - GitHub issue #65006 on llvm/llvm-project suggests to NOT use -fcoverage-prefix-map
+          # - macos runner has AppleClang 14 which doesn't support -fcoverage-compilation-dir
+          # workaround is to store root path to help use coverage directory mappings later
+          cd ../..
+          echo "$PWD" >> upload/cov/${{ env.UNIQUE_NAME }}/patomic.rootpath
+
+      - name: Generate Lcov Tracefile and Root Path File (gcc)
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage' && matrix.compiler == 'gcc'
+        shell: bash
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          # install lcov
+          sudo apt install lcov
+          
+          # set up directory
+          mkdir -p upload/cov/${{ env.UNIQUE_NAME }}
+          
+          # merge all .gcda files into an lcov tracefile, and copy to upload
+          # all tests have .gcno, but only tests that were executed have .gcda
+          lcov --directory --rc lcov_branch_coverage=1 patomic/build --capture --output-file patomic.lcov
+          cp patomic.lcov upload/cov/${{ env.UNIQUE_NAME }}/patomic.lcov
+          
+          # we need root path file because next job needs it because clang (above) needs it
+          # we might also need it for gcc, but at the moment i have no idea
+          echo "$PWD" >> upload/cov/${{ env.UNIQUE_NAME }}/patomic.rootpath
+
+      - name: Upload Internal Coverage Results
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage'
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-coverage-internal
+          path: ${{ env.ROOT_PATH }}/upload/cov/
diff --git a/.github/workflows/_reusable-test-qemu.yml b/.github/workflows/_reusable-test-qemu.yml
new file mode 100644
index 000000000..0b48097fa
--- /dev/null
+++ b/.github/workflows/_reusable-test-qemu.yml
@@ -0,0 +1,276 @@
+name: Run Tests on Emulated Platforms
+
+on:
+  workflow_call:
+    inputs:
+      triple:
+        description: 'Platform triple to compile for, used to install correct compiler and sysroot'
+        required: true
+        type: string
+        default: ''
+      architecture:
+        description: 'CPU architecture tests are run on, passed to QEMU'
+        required: true
+        type: string
+        default: ''
+      gcc_version:
+        description: 'Major version of GCC toolchain to use'
+        required: false
+        type: number
+        default: 10
+      llvm_version:
+        description: 'Major version of LLVM toolchain to use'
+        required: false
+        type: number
+        default: 15
+      skip_gcc:
+        description: "Conditionally skip 'run' job if gcc does not support the triple"
+        required: false
+        type: boolean
+        default: false
+      skip_llvm:
+        description: "Conditionally skip 'run' job if llvm does not support the triple"
+        required: false
+        type: boolean
+        default: false
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check No Empty Inputs
+        shell: bash
+        run: |
+          # checks input is not empty
+          check_input() {
+            input_name="${1}"
+            input_value="${2}"
+            if [[ -z "${input_value}" ]]; then
+              echo "Error: required input '${input_name}' is empty or was not specified."
+              exit 1
+            else
+              echo "${input_name}: ${input_value}"
+            fi
+          }
+
+          # apply function to all inputs
+          check_input "triple"       "${{ inputs.triple }}"
+          check_input "architecture" "${{ inputs.architecture }}"
+
+  run:
+    needs: check
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        compiler:
+          - clang
+          - gcc
+        kind:
+          - ansi
+          - coverage
+          - sanitize
+          - warning
+        build_shared:
+          - static
+          - shared
+        exclude:
+          # fixme: clang can't find runtime coverage libraries (GHI #28)
+          - kind: coverage
+            compiler: clang
+          # fixme: not sure which archs asan/ubsan support (GHI #29)
+          - kind: sanitize
+        include:
+          # alias variables
+          - compiler: clang
+            compiler_version: ${{ inputs.llvm_version }}
+            preset: patomic-ci-qemu-ubuntu-clang
+          - compiler: gcc
+            compiler_version: ${{ inputs.gcc_version }}
+            preset: patomic-ci-qemu-ubuntu-gcc
+          - build_shared: static
+            cmake_build_shared: OFF
+          - build_shared: shared
+            cmake_build_shared: ON
+    env:
+      # required by toolchain file
+      PATOMIC_CI_XARCH: ${{ inputs.architecture }}
+      PATOMIC_CI_XTRIPLE: ${{ inputs.triple }}
+      PATOMIC_CI_XCOMPILER: ${{ matrix.compiler }}
+      PATOMIC_CI_XCOMPILER_VERSION: ${{ matrix.compiler_version }}
+      PATOMIC_CI_SYSROOT: /Users/${{ matrix.kind }}/sysroot
+      # these have to be in env context because they depend on matrix context
+      UNIQUE_ID: ${{ matrix.kind }}-${{ inputs.triple }}-ubuntu-${{ matrix.compiler }}-${{ matrix.build_shared }}-${{ inputs.gcc_version }}-${{ inputs.llvm_version }}
+      UNIQUE_NAME: ${{ inputs.architecture }}-ubuntu-${{ matrix.compiler }}-${{ matrix.build_shared }}
+      CMAKE_PRESET: ${{ matrix.preset }}-${{ matrix.kind }}
+      CMAKE_BUILD_TYPE: ${{ ( matrix.kind == 'ansi' || matrix.kind == 'warning' ) && 'Release' || 'Debug' }}
+      # apparently not possible to skip job using matrix context, so this is the next best thing
+      SKIP_JOB: >-
+        ${{ (
+          ( matrix.compiler == 'clang' && inputs.skip_llvm ) ||
+          ( matrix.compiler == 'gcc' && inputs.skip_gcc )
+        ) }}
+      # necessary because publish-coverage job expects everything to have the same root path
+      ROOT_PATH: /Users/${{ matrix.kind }}
+
+    steps:
+      - name: Install Toolchains and Qemu
+        if: env.SKIP_JOB != 'true'
+        run: |
+          sudo apt update
+          sudo apt install g++-${{ inputs.gcc_version }}-multilib
+          sudo apt install g++-${{ inputs.gcc_version }}-${{ inputs.triple }}
+          if [[ '${{ matrix.compiler }}' == 'clang' ]]; then
+            sudo apt install llvm-${{ inputs.llvm_version }}
+            sudo apt install clang-${{ inputs.llvm_version }}
+          fi
+          sudo apt install qemu-user
+
+      - name: Set Up Root Path
+        if: env.SKIP_JOB != 'true'
+        run: |
+          sudo mkdir -p "${{ env.ROOT_PATH }}"
+          sudo chmod -R a+rwx "${{ env.ROOT_PATH }}"
+
+      - name: Checkout patomic
+        if: env.SKIP_JOB != 'true'
+        uses: actions/checkout@v4
+        with:
+          # cannot prefix with ROOT_PATH here, so copy over in next step
+          path: patomic
+
+      - name: Move patomic to Root Path Location
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cp -R ./patomic/ "${{ env.ROOT_PATH }}/patomic"
+
+      - name: Restore Cached Sysroot (with GoogleTest)
+        if: env.SKIP_JOB != 'true'
+        id: cache-sysroot
+        uses: actions/cache@v3
+        with:
+          path: ${{ env.ROOT_PATH }}/sysroot
+          key: sysroot-${{ env.UNIQUE_ID }}
+
+      - name: Set Up Sysroot
+        if: env.SKIP_JOB != 'true' && steps.cache-sysroot.outputs.cache-hit != 'true'
+        run: |
+          cp -R /usr/${{ inputs.triple }}/ ${{ env.ROOT_PATH }}/sysroot
+
+      - name: Checkout GoogleTest
+        if: env.SKIP_JOB != 'true' && steps.cache-sysroot.outputs.cache-hit != 'true'
+        uses: actions/checkout@v4
+        with:
+          repository: google/googletest
+          # cannot prefix with ROOT_PATH here, so copy over in next step
+          path: googletest
+
+      - name: Move GoogleTest to Root Path Location
+        if: env.SKIP_JOB != 'true' && steps.cache-sysroot.outputs.cache-hit != 'true'
+        run: |
+          cp -R ./googletest/ "${{ env.ROOT_PATH }}/googletest"
+
+      - name: Build and Install GoogleTest
+        if: env.SKIP_JOB != 'true' && steps.cache-sysroot.outputs.cache-hit != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd googletest
+          cp -R ../patomic/ci/ ./
+          cp ../patomic/CMakePresets.json .
+          mkdir build
+          cd build
+          cmake --preset ${{ env.CMAKE_PRESET }} -DBUILD_TESTING=OFF -DCMAKE_CXX_FLAGS="" -DBUILD_SHARED_LIBS=${{ matrix.cmake_build_shared }} -DCMAKE_BUILD_TYPE=${{ env.CMAKE_BUILD_TYPE }} -Dgtest_force_shared_crt=ON -Dgtest_hide_internal_symbols=ON ..
+          cmake --build . --verbose
+          cmake --install . --prefix "${{ env.ROOT_PATH }}/sysroot"
+
+      # TODO: figure out how to cache this for builds where we don't change any source code (GHI #31)
+      - name: Build patomic
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd patomic
+          mkdir build
+          cd build
+          cmake --preset ${{ env.CMAKE_PRESET }} -DBUILD_SHARED_LIBS=${{ matrix.cmake_build_shared }} -DCMAKE_BUILD_TYPE=${{ env.CMAKE_BUILD_TYPE }} -DGTest_ROOT:PATH="${{ env.ROOT_PATH }}/sysroot" ..
+          cmake --build . --verbose
+
+      - name: Test patomic
+        if: env.SKIP_JOB != 'true'
+        continue-on-error: true
+        env:
+          LABEL_REGEX: ${{ matrix.kind == 'coverage' && '^(ut)$' || '^(.*)$' }}
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          cd patomic/build
+          ctest --label-regex "${{ env.LABEL_REGEX }}" --verbose --output-junit Testing/Temporary/results.xml .
+
+      - name: Prepare Test Results
+        if: env.SKIP_JOB != 'true'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          mkdir -p upload/test/${{ matrix.kind }}
+          python3 patomic/test/improve_ctest_xml.py --input patomic/build/Testing/Temporary/results.xml --triple ${{ matrix.kind }}-${{ env.UNIQUE_NAME }} --output upload/test/${{ matrix.kind }}/${{ env.UNIQUE_NAME }}.xml
+
+      - name: Upload Test Results
+        if: env.SKIP_JOB != 'true'
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-results
+          path: ${{ env.ROOT_PATH }}/upload/test/
+
+      - name: Generate Lcov Tracefile and Root Path File (clang)
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage' && matrix.compiler == 'clang'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          # set up directory
+          mkdir -p upload/cov/${{ env.UNIQUE_NAME }}
+          cd patomic/build
+          
+          # need llvm tools to be installed
+          sudo apt install llvm
+
+          # merge coverage output from all tests
+          # use find because bash on GitHub Actions currently does not support '**'
+          find test/working -type f -name "*.profraw" -print0 | xargs -0 llvm-profdata merge -output=patomic.profdata
+          
+          # convert profdata to lcov tracefile, and copy to upload
+          lib_files=(libpatomic.*)  # matches shared/static lib files and symlinks
+          llvm-cov export -format=lcov -instr-profile=patomic.profdata -object="${lib_files[0]}" >> patomic.lcov
+          cp patomic.lcov ../../upload/cov/${{ env.UNIQUE_NAME }}/patomic.lcov
+          
+          # necessary because publish-coverage expects this file due to workaround for native test coverage
+          cd ../..
+          echo "$PWD" >> upload/cov/${{ env.UNIQUE_NAME }}/patomic.rootpath
+
+      - name: Generate Lcov Tracefile and Root Path File (gcc)
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage' && matrix.compiler == 'gcc'
+        run: |
+          cd ${{ env.ROOT_PATH }}
+          
+          # install lcov
+          sudo apt install lcov
+          
+          # set up directory
+          mkdir -p upload/cov/${{ env.UNIQUE_NAME }}
+          
+          # merge all .gcda files into an lcov tracefile, and copy to upload
+          # all tests have .gcno, but only tests that were executed have .gcda
+          lcov --directory --rc lcov_branch_coverage=1 patomic/build --capture --output-file patomic.lcov
+          cp patomic.lcov upload/cov/${{ env.UNIQUE_NAME }}/patomic.lcov
+          
+          # we need root path file because next job needs it because clang (above) needs it
+          # we might also need it for gcc, but at the moment i have no idea
+          echo "$PWD" >> upload/cov/${{ env.UNIQUE_NAME }}/patomic.rootpath
+
+      - name: Upload Internal Coverage Results
+        if: env.SKIP_JOB != 'true' && matrix.kind == 'coverage'
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-coverage-internal
+          path: ${{ env.ROOT_PATH }}/upload/cov/
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..104b86925
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,371 @@
+name: Run All Tests
+
+on:
+  pull_request:
+    branches:
+      - '**'
+  schedule:
+    - cron: "0 0 * * *"
+
+jobs:
+  test-native:
+    strategy:
+      matrix:
+        # verbose labels make things easier to read in GitHub Actions
+        # platform gets converted to os, preset, compiler, architecture
+        platform:
+          - windows-gcc-x86_64
+          - windows-msvc-x86_32
+          - windows-msvc-x86_64
+          - macos-clang-x86_64
+          - linux-clang-x86_64
+          - linux-gcc-x86_64
+        # convert verbose labels to usable values (which don't appear in GitHub Actions GUI)
+        include:
+          # platform -> os, preset, compiler, architecture
+          - platform: windows-gcc-x86_64
+            os: windows
+            preset: patomic-ci-native-unix-gcc
+            architecture: x86_64
+          - platform: windows-msvc-x86_32
+            os: windows
+            preset: patomic-ci-native-win32-msvc
+            architecture: x86_32
+          - platform: windows-msvc-x86_64
+            os: windows
+            preset: patomic-ci-native-win64-msvc
+            architecture: x86_64
+          - platform: macos-clang-x86_64
+            os: macos
+            preset: patomic-ci-native-unix-clang
+            architecture: x86_64
+          - platform: linux-clang-x86_64
+            os: ubuntu
+            preset: patomic-ci-native-unix-clang
+            architecture: x86_64
+          - platform: linux-gcc-x86_64
+            os: ubuntu
+            preset: patomic-ci-native-unix-gcc
+            architecture: x86_64
+
+    uses: ./.github/workflows/_reusable-test-native.yml
+    with:
+      os: ${{ matrix.os }}
+      preset: ${{ matrix.preset }}
+      architecture: ${{ matrix.architecture }}
+
+  test-qemu:
+    strategy:
+      matrix:
+        # architecture gets converted to triple
+        # short form here so that it doesn't take up the whole GitHub Action name
+        architecture:
+          - aarch64
+          - alpha
+          - arm
+          - armhf
+          - hppa
+          - m68k
+          - mips
+          - mips64
+          - mips64el
+          - mipsel
+          - ppc
+          - ppc64
+          - ppc64le
+          - riscv64
+          - s390x
+          - sh4
+          - sparc64
+          - x86_32
+        # convert architectures to triples
+        include:
+          - architecture: aarch64
+            triple: aarch64-linux-gnu
+          - architecture: alpha
+            triple: alpha-linux-gnu
+            skip_llvm: true  # unsupported
+          - architecture: arm
+            triple: arm-linux-gnueabi
+          - architecture: armhf
+            triple: arm-linux-gnueabihf
+          - architecture: hppa
+            triple: hppa-linux-gnu
+            skip_llvm: true  # unsupported
+          - architecture: m68k
+            triple: m68k-linux-gnu
+            skip_gcc: true  # fixme: segfaults qemu (GHI #25)
+            skip_llvm: true  # fixme: ICEs clang (GHI #25)
+          - architecture: mips
+            triple: mips-linux-gnu
+          - architecture: mips64
+            triple: mips64-linux-gnuabi64
+          - architecture: mips64el
+            triple: mips64el-linux-gnuabi64
+          - architecture: mipsel
+            triple: mipsel-linux-gnu
+          - architecture: ppc
+            triple: powerpc-linux-gnu
+          - architecture: ppc64
+            triple: powerpc64-linux-gnu
+          - architecture: ppc64le
+            triple: powerpc64le-linux-gnu
+          - architecture: riscv64
+            triple: riscv64-linux-gnu
+            skip_gcc: true  # fixme: errors on system header (GHI #25)
+          - architecture: s390x
+            triple: s390x-linux-gnu
+          - architecture: sh4
+            triple: sh4-linux-gnu
+            skip_gcc: true  # fixme: segfaults qemu (GHI #25)
+            skip_llvm: true  # unsupported
+          - architecture: sparc64
+            triple: sparc64-linux-gnu
+            skip_gcc: true  # fixme: segfaults qemu (GHI #25)
+          - architecture: x86_32
+            triple: i686-linux-gnu
+            skip_llvm: true  # fixme: supported but not sure how (GHI #25)
+    uses: ./.github/workflows/_reusable-test-qemu.yml
+    with:
+      triple: ${{ matrix.triple }}
+      architecture: ${{ matrix.architecture }}
+      skip_gcc: ${{ matrix.skip_gcc == true }}
+      skip_llvm: ${{ matrix.skip_llvm == true }}
+
+  publish-results:
+    runs-on: ubuntu-latest
+    needs:
+      - test-native
+      - test-qemu
+
+    steps:
+      - name: Download Test Result Artifacts
+        uses: actions/download-artifact@v3
+        with:
+          name: test-results
+          path: test-results/
+
+      - name: Publish Test Results
+        uses: EnricoMi/publish-unit-test-result-action@v2
+        with:
+          action_fail: true
+          action_fail_on_inconclusive: true
+          check_name: "Test Results"
+          files: test-results/**/*.xml
+
+  publish-coverage:
+    runs-on: ubuntu-latest
+    needs:
+      - test-native
+      - test-qemu
+    env:
+      HI_LIMIT: 100
+      MED_LIMIT: 90
+
+    steps:
+      - name: Install Dependencies
+        run: |
+          sudo apt update
+          sudo apt install binutils  # for c++filt
+          sudo apt install lcov
+
+      - name: Download Test Coverage Artifacts
+        uses: actions/download-artifact@v3
+        with:
+          name: test-coverage-internal
+          path: test-coverage-internal/
+
+      - name: Checkout patomic
+        uses: actions/checkout@v4
+        with:
+          path: patomic
+
+      - name: Copy patomic To All Original Mapping Paths
+        run: |
+          # go through all patomic.rootpath files
+          find ./test-coverage-internal -type f -name '*patomic.rootpath' -print0 | while IFS= read -r -d '' rp_file; do
+            root_path=$(cat "${rp_file}")
+          
+            # create root_path if it doesn't exist
+            if [ ! -d "${root_path}" ]; then
+              sudo mkdir -p "${root_path}"
+            fi
+          
+            # ensure full permissions even if it exists
+            sudo chmod -R a+rwx "${root_path}"
+
+            # copy patomic to root_path if not already there
+            if [ ! -d "${root_path}/patomic" ]; then
+              cp -R ./patomic/ "${root_path}/patomic"
+            fi
+          done
+
+      - name: Create Lcov Config File
+        run: |
+          touch ./lcovrc
+          echo "genhtml_hi_limit = ${{ env.HI_LIMIT }}" >> ./lcovrc
+          echo "genhtml_med_limit = ${{ env.MED_LIMIT }}" >> ./lcovrc
+          echo "genhtml_function_hi_limit = ${{ env.HI_LIMIT }}" >> ./lcovrc
+          echo "genhtml_function_med_limit = ${{ env.MED_LIMIT }}" >> ./lcovrc
+          echo "lcov_branch_coverage = 1" >> ./lcovrc
+          
+          echo "Contents of lcovrc:"
+          cat ./lcovrc
+          sudo cp ./lcovrc /etc/lcovrc
+
+      - name: Generate HTML Files
+        run: |
+          # directory into which to put user artifacts
+          mkdir test-coverage
+          
+          # keep only coverage data relating to patomic source files
+          find ./test-coverage-internal -mindepth 1 -maxdepth 1 -type d -not -path '*/.*' -print0 | while IFS= read -r -d '' dir; do
+            mv "${dir}/patomic.lcov" "${dir}/patomic.lcov.old"
+            root_path=$(cat "${dir}/patomic.rootpath")
+            lcov --output-file "${dir}/patomic.lcov" --extract "${dir}/patomic.lcov.old" "${root_path}/patomic/src/*"
+          done
+          
+          # generate html files for each separate compilation
+          find ./test-coverage-internal -mindepth 1 -maxdepth 1 -type d -not -path '*/.*' -print0 | while IFS= read -r -d '' dir; do
+            arch=$(basename "${dir}")
+            mkdir "./test-coverage/${arch}"
+            genhtml --output-directory "./test-coverage/${arch}" --title "patomic-${arch}" --show-details --num-spaces 4 --legend --demangle-cpp --precision 2 "./test-coverage-internal/${arch}/patomic.lcov"
+          done
+          
+          # generate html files for combined coverage
+          mkdir ./test-coverage-internal/universal
+          mkdir ./test-coverage/universal
+          lcov_combine_command=("lcov")
+          while IFS= read -r -d '' lcov_file; do
+            lcov_combine_command+=("-a" "${lcov_file}")
+          done < <(find ./test-coverage-internal -type f -name '*patomic.lcov' -print0)
+          lcov_combine_command+=("-o" "./test-coverage-internal/universal/patomic.lcov")
+          "${lcov_combine_command[@]}"
+          genhtml --output-directory "./test-coverage/universal" --title "patomic-universal" --show-details --num-spaces 4 --legend --demangle-cpp --precision 2 ./test-coverage-internal/universal/patomic.lcov
+
+      - name: Upload Coverage HTML Results
+        uses: actions/upload-artifact@v3
+        with:
+          name: test-coverage
+          path: test-coverage/
+
+      - name: Parse Lcov Files Into Json
+        shell: python
+        run: |
+          import json, os, pathlib, re, subprocess
+            
+          # constants
+          BASE_DIR = pathlib.Path("./test-coverage-internal")
+          OUT_FILE = pathlib.Path("./patomic.json")
+          
+          # iterate through all non-hidden directories
+          archs = [d for d in next(os.walk(BASE_DIR))[1] if d[0] != '.']
+          json_out = {}
+          for arch in archs:
+            json_out[arch] = {}
+            
+            # get lcov summary
+            cmd = f"lcov --summary {BASE_DIR / arch / 'patomic.lcov'}"
+            lcov_summary = subprocess.run(cmd, capture_output=True, shell=True, check=True).stdout.decode()
+            
+            # go through lcov summary
+            for line in lcov_summary.split('\n'):
+              
+              # skip non-info lines
+              if not line.lstrip().startswith(("lines", "functions", "branches")):
+                continue
+              attr_name = line.lstrip().split('.', maxsplit=1)[0]
+              
+              # parse coverage stats
+              if "no data found" in line:
+                json_out[arch][attr_name] = { "percentage": 100.0, "count": 0, "total": 0 }
+              else:
+                pattern = r"(\d+\.\d+)% \((\d+) of (\d+)"
+                matches = re.findall(pattern, line)[0]
+                json_out[arch][attr_name] = { "percentage": float(matches[0]), "count": int(matches[1]), "total": int(matches[2]) }
+          
+          # write results to json file
+          with open(OUT_FILE, 'w') as f:
+            json.dump(json_out, f, indent=4)
+
+      - name: Create Coverage Summary File
+        id: coverage-summary
+        shell: python
+        run: |
+          import json, os, pathlib
+          
+          # constants
+          HI_LIMIT, MED_LIMIT = ${{ env.HI_LIMIT }}, ${{ env.MED_LIMIT }}
+          IN_FILE = pathlib.Path("./patomic.json")
+          OUT_FILE = pathlib.Path("./patomic.md")
+          
+          # markdown prologue
+          md_string = "## Test Coverage\n"
+          md_string += "| Target | Lines | Functions | Branches |\n"
+          md_string += "| :----- | ----: | --------: | -------: |\n"
+          
+          # keep track of issues
+          any_fail = False
+          universal_warn = False
+          
+          # go through json file
+          with open(IN_FILE, 'r') as fp:
+            j = json.load(fp)
+            
+            # sort archs except for 'universal' first
+            archs = sorted(list(j.keys()), key=lambda a: a if a != 'universal' else '\0')
+            
+            # helper lambdas
+            make_emoji = lambda p: "✅" if p >= HI_LIMIT else ("⚠️" if p >= MED_LIMIT else "🛑")
+            field_2str = lambda f: f"{float(f['percentage']):.2f}% ({f['count']}/{f['total']}) {make_emoji(float(f['percentage']))}"
+            
+            # write summary of each arch to markdown file
+            for arch in archs:
+              line = f"| `{arch}` | `{field_2str(j[arch]['lines'])}` | `{field_2str(j[arch]['functions'])}` | `{field_2str(j[arch]['branches'])}` |\n"
+              md_string += line
+              
+              # check for warnings
+              any_fail = any_fail or "🛑" in line
+              universal_warn = universal_warn or (arch == 'universal' and "⚠️" in line)
+          
+          # markdown epilogue
+          sha = "${{ github.event.pull_request.head.sha }}"
+          md_string += f"\n`✅ >= {HI_LIMIT}`, `⚠️ >= {MED_LIMIT}`, `🛑 < {MED_LIMIT}`\n"
+          md_string += f"\nResults for commit [`{sha[:8]}`](${{ github.server_url }}/${{ github.repository }}/commit/{sha}).\n"
+          
+          # write to file
+          with open(OUT_FILE, 'wb') as fp:
+            fp.write(md_string.encode())
+          
+          # write checks to output
+          with open(os.environ["GITHUB_OUTPUT"], 'a') as fp:
+            print(f"any_fail={int(any_fail)}", file=fp)
+            print(f"universal_warn={int(universal_warn)}", file=fp)
+
+      - name: Publish Coverage in Job Summary
+        run: |
+          echo "$(cat ./patomic.md)" >> $GITHUB_STEP_SUMMARY
+
+      - name: Publish Coverage in Pull Request Comment
+        uses: thollander/actions-comment-pull-request@v2
+        with:
+          comment_tag: coverage-summary
+          filePath: patomic.md
+
+      - name: Fail if Any Coverage Fails or Universal Coverage Warns
+        run: |
+          if [[ '${{ steps.coverage-summary.outputs.any_fail }}' != '0' ]]; then
+            msg="At least one coverage result was below the threshold of ${{ env.MED_LIMIT }}% (a.k.a. 🛑)"
+            echo "::error title=Any Coverage Failure::${msg}"
+            exit 1
+          elif [[ '${{ steps.coverage-summary.outputs.universal_warn }}' != '0' ]]; then
+            msg="Universal coverage result was below the threshold of ${{ env.HI_LIMIT }}% (a.k.a. ⚠️)"
+            echo "::error title=Universal Coverage Warning::${msg}"
+            exit 1
+          fi
+
+      # keep this last so that we have it for debugging if something fails
+      - name: Delete Test Coverage Artifacts
+        uses: geekyeggo/delete-artifact@v2
+        with:
+          name: test-coverage-internal
\ No newline at end of file
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 5d79d5479..739c8e374 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -26,30 +26,52 @@ if(NOT TARGET patomic::patomic)
     find_package(patomic QUIET)
 endif()
 
-# for Windows: prevent overriding the parent project's compiler/linker settings
-set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+# get GTest
 find_package(GTest REQUIRED)
 include(GoogleTest)
 
+# sometimes this variable is not correctly set by GTest's find_package
+if(NOT GTEST_INCLUDE_DIRS)
+    if("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.20.0")
+        get_target_property(
+            GTEST_INCLUDE_DIRS
+            GTest::gtest INTERFACE_SYSTEM_INCLUDE_DIRECTORIES
+        )
+    else()
+        get_target_property(
+            GTEST_INCLUDE_DIRS
+            GTest::GTest INTERFACE_SYSTEM_INCLUDE_DIRECTORIES
+        )
+    endif()
+endif()
+
+# get libatomic if it exists (.so.1 in case of no namelink, e.g. FC19 and Ubuntu)
+# courtesy of HP's foedus_code's FindGccAtomic.cmake (the .so.1 part)
+find_library(libatomic NAMES atomic atomic.so.1 libatomic.so.1)
+
+# link libatomic to GTest if it exists
+# necessary for using GTest on platforms without atomic operations where
+# libatomic is not automatically linked (e.g. clang-15 for arm-linux-gnueabi)
+if(libatomic)
+    set_property(
+        TARGET GTest::gtest
+        APPEND PROPERTY INTERFACE_LINK_LIBRARIES "${libatomic}"
+    )
+endif()
+
 
 # ---- Declare Tests ----
 
+# base target that indirectly depends on all tests
 add_custom_target(patomic-test)
 
-# only add BTs if patomic target is available
-if(TARGET patomic::patomic)
-    add_subdirectory(bt)
-else()
-    message(STATUS "Skipping binary tests; patomic target not available")
-endif()
+# always add include and src; used by and linked to all test targets
+# must be added first to create targets that we use when creating test targets
+add_subdirectory(include)
+add_subdirectory(src)
 
-# only add UTs if patomic files are available
-# these are currently set by patomic before including this project
-if(PATOMIC_BINARY_DIR AND PATOMIC_SOURCE_DIR)
-   add_subdirectory(ut)
-else()
-    message(STATUS "Skipping unit tests; not building as sub-project of patomic")
-endif()
+# add directory with all test kinds
+add_subdirectory(kind)
 
 
 # ---- Support Packaging Library ----
diff --git a/test/cmake/CreateTest.cmake b/test/cmake/CreateTest.cmake
index d30daab9c..acd2c9eba 100644
--- a/test/cmake/CreateTest.cmake
+++ b/test/cmake/CreateTest.cmake
@@ -4,11 +4,28 @@
 # Expects a target named patomic-test-${kind} to exist.
 # E.g. if you call it as create_test(BT ...) then patomic-test-bt must exist.
 #
+# Kinds:
+# - BT (binary test), intended to test public API of compiled library
+# - ST (system test), intended to test host system and frameworks/sanitizers used
+# - UT (unit test), intended to test specific function in library source file
+#
 # Naming:
 # - created target -> patomic-test-${kind}-${name} (e.g. patomic-test-bt-SomeExample)
 # - executable name -> ${name} (e.g. SomeExample on Unix or SomeExample.exe on Windows)
 # - install directory -> ${CMAKE_INSTALL_TESTDIR}/${kind} (e.g. share/test/bt)
 #
+# Labels:
+# - each test (not target) will have ${kind} appended to its LABELS property
+# - main use case is for code coverage to be able to only run unit tests
+#
+# Working Directory (CTest):
+# - each test target has its working directory set to ${PROJECT_BINARY_DIR}/working/${kind}/${name}
+# - if a multi-config generator is used, 'working' is replaced with 'working/$<CONFIG>'
+# - reasoning:
+#   - coverage files generated by executables may overwrite existing coverage files (e.g. with clang)
+#   - this is an issue if multiple test executables exist in the same directory
+#   - this solution solves that by running each executable in its own directory
+#
 # Hierarchy:
 # - patomic-test -> base custom target & component (build/install) for all tests
 # - patomic-test-${kind} -> custom target & component (build install) for all tests of a specific kind
@@ -24,8 +41,8 @@ function(_create_test)
 
     # setup what arguments we expect
 
-    set(all_kinds "BT;UT")  # list of all kinds we can iterate over
-    set(all_kinds_opt_msg "BT|UT")  # string to use in debug message
+    set(all_kinds "BT;ST;UT")  # list of all kinds we can iterate over
+    set(all_kinds_opt_msg "BT|ST|UT")  # string to use in debug message
 
     cmake_parse_arguments(
         "ARG"
@@ -101,22 +118,33 @@ function(_create_test)
     endif()
 
     # create target with sources
-    add_executable(
-        ${target}
+    add_executable(${target})
+
+    # add sources to target
+    target_sources(
+        ${target} PRIVATE
         ${ARG_SOURCE}
     )
 
     # add include directories
     target_include_directories(
         ${target} PRIVATE
+        "$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>"
         ${ARG_INCLUDE}
     )
 
+    # add /src and /include sources that apply to all test targets
+    target_link_libraries(
+        ${target} PRIVATE
+        patomic-test-include
+        patomic-test-src
+    )
+
     # update dependencies list directly because we use it in Windows PATH stuff later
     if("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.20.0")
         list(APPEND ARG_LINK GTest::gtest_main)
-        # TODO: this prevents test lookup on Windows; fix once pipeline exists
-        # TODO: see https://github.com/google/googletest/issues/2157
+        # TODO: this prevents test lookup on Windows; fix once pipeline exists (GHI #30)
+        # TODO: see https://github.com/google/googletest/issues/2157 (GHI #30)
         # if ("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.23.0")
         #     list(APPEND ARG_LINK GTest::gmock)
         # endif()
@@ -140,7 +168,7 @@ function(_create_test)
     string(TOUPPER "${kind}" kind_upper)
     target_compile_definitions(
         ${target} PRIVATE
-        "PATOMIC_${kind_upper}"
+        "PATOMIC_TEST_KIND_${kind_upper}=1"
     )
 
     # set binary name instead of using default
@@ -152,49 +180,76 @@ function(_create_test)
 
     # register test with GTest/CTest and parent target
 
+    # setup path differently depending on generator
+    get_property(is_multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
+    set(test_working_dir "${PROJECT_BINARY_DIR}/working")
+    if(is_multi_config)
+        set(test_working_dir "${test_working_dir}/$<CONFIG>/${kind}/${name}")
+    else()
+        set(test_working_dir "${test_working_dir}/${kind}/${name}")
+    endif()
+
     # must be run in same directory scope as target
     gtest_add_tests(
         TARGET ${target}
         TEST_LIST added_tests
+        WORKING_DIRECTORY "${test_working_dir}"
     )
 
-    add_dependencies(${parent_target} ${target})
-
+    # add label to tests so ctest can run them by kind
+    foreach(test IN LISTS added_tests)
+        set_property(
+            TEST "${test}"
+            APPEND PROPERTY LABELS "${kind}"
+        )
+    endforeach()
 
-    # deal with Windows runtime linker issues
+    # custom target to make sure the working directory exists for the test
+    add_custom_target(
+        ${target}-create-working-dir
+        COMMAND "${CMAKE_COMMAND}" -E make_directory "${test_working_dir}"
+    )
 
-    if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
+    # set build dependencies
+    add_dependencies(${target} ${target}-create-working-dir)
+    add_dependencies(${parent_target} ${target})
 
-        # check we actually care about Windows PATH stuff
-        if(NOT PATOMIC_WINDOWS_SET_CTEST_PATH_ENV AND
-           NOT PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE)
-            return()
-        endif()
 
-        # get paths to all shared library dependencies (DLLs)
-        windows_deps_paths(
-            deps_paths
-            ${ARG_LINK}
-        )
+    # deal with Windows runtime linker lookup issues
 
-        # set environment variable for each test so that CTest works
-        if(deps_paths AND PATOMIC_WINDOWS_SET_CTEST_PATH_ENV)
-            foreach(test IN LISTS added_tests)
-                set_property(
-                    TEST "${test}"
-                    PROPERTY ENVIRONMENT "PATH=${deps_paths}"
-                )
-            endforeach()
-        endif()
+    if(CMAKE_SYSTEM_NAME STREQUAL "Windows" AND
+        PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV)
 
-        # make dependencies accessible from parent target
-        # so that we can create single file for all tests in kind
-        if(PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE)
-            set_property(
-                TARGET ${parent_target}
-                APPEND PROPERTY WIN_DEPS_TARGETS "${ARG_LINK}"
+        # get paths to all shared library dependencies (DLLs)
+        # this should just be patomic and gtest
+        set(deps_paths )
+        foreach(dep_target IN LISTS ARG_LINK)
+            # This will fail if passed a link option that isn't a target
+            # This is intentional; don't do that.
+            # Instead, create an IMPORTED library, and set its target properties
+            # such as IMPORTED_LOCATION for the library path and set
+            # INTERFACE_INCLUDE_DIRECTORIES to the directories containing any
+            # necessary header files.
+            if(NOT TARGET "${dep_target}")
+                message(FATAL_ERROR "Encountered non-target dependency '${dep_target}' for target ${target} (check source comment)")
+            endif()
+            get_target_property(type "${dep_target}" TYPE)
+            if(type STREQUAL "SHARED_LIBRARY")
+                list(APPEND deps_paths "$<TARGET_FILE_DIR:${dep_target}>")
+            endif()
+        endforeach()
+
+        # tidy up the paths
+        list(REMOVE_DUPLICATES deps_paths)
+        string(REPLACE ";" "\;" deps_paths "${deps_paths}")
+
+        # modify environment variable for each test so that CTest can find DLLs
+        foreach(test IN LISTS added_tests)
+            set_tests_properties(
+                "${test}" PROPERTIES
+                ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:${deps_paths}"
             )
-        endif()
+        endforeach()
 
     endif()
 
@@ -257,6 +312,30 @@ function(create_bt)
 endfunction()
 
 
+# Creates target patomic-test-st-${name} corresponding to ST test executable.
+#
+# create_st(
+#     NAME <name>
+# )
+function(create_st)
+
+    cmake_parse_arguments(
+        "ARG"
+        ""
+        "NAME"
+        "SOURCE"
+        ${ARGN}
+    )
+
+    _create_test(
+        ${ARG_UNPARSED_ARGUMENTS}
+        ST     ${ARG_NAME}
+        SOURCE ${ARG_SOURCE}
+    )
+
+endfunction()
+
+
 # Creates target patomic-test-ut-${name} corresponding to UT test executable.
 #
 # create_ut(
@@ -290,89 +369,3 @@ function(create_ut)
     target_compile_definitions(${target_name} PRIVATE PATOMIC_STATIC_DEFINE)
 
 endfunction()
-
-
-# ---- Create Test Dependency File ----
-
-# Creates a file containing the output of windows_deps_paths for all tests of
-# the given kind registered so far.
-# The file path will be "${CMAKE_CURRENT_BINARY_DIR}/windows_dependencies_path.txt".
-# Expects a target named patomic-test-${kind} to exist
-# E.g. if you call it as create_test_win_deps_paths_file(BT) then patomic-bt must
-# exist.
-# This function has no effect when not running on Windows.
-#
-# create_test_win_deps_paths_file(
-#     BT|UT
-# )
-function(create_test_win_deps_paths_file ARG_KIND)
-
-    # check we actually want to generate file
-
-    if(NOT PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE)
-        return()
-    endif()
-
-
-    # check KIND is valid
-
-    set(all_kinds_opt_msg "BT|UT")
-    set(func_name "create_test_win_deps_paths_file")
-
-    if(NOT ARG_KIND MATCHES "^(${all_kinds_option})$")
-        message(WARNING "${all_kinds_option} option needs to be specified when invoking '${func_name}'")
-        message(FATAL_ERROR "Aborting '${func_name}' due to invalid arguments")
-    endif()
-
-    string(TOLOWER ${ARG_KIND} kind)
-
-
-    # create and install file with dependencies path
-
-    if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
-
-        # get dependencies set by create_test from target
-        get_target_property(dep_targets patomic-test-${kind} WIN_DEP_TARGETS)
-        if("${dep_targets}" STREQUAL "dep_targets-NOTFOUND")
-            message(VERBOSE "Skipping creation of Windows dependencies PATH file for ${ARG_KIND}; no relevant test targets created")
-            return()
-        endif()
-        list(REMOVE_DUPLICATES dep_targets)
-
-        # get paths to all shared library dependencies (DLLs)
-        windows_deps_paths(
-            deps_paths
-            ${dep_targets}
-        )
-
-        # create file
-        set(file_path "${CMAKE_CURRENT_BINARY_DIR}/windows_dependencies_path.txt")
-        file(GENERATE
-            OUTPUT ${file_path}
-            CONTENT "${deps_paths}"
-        )
-
-        # copy file to install location
-        if(NOT CMAKE_SKIP_INSTALL_RULES)
-
-            # install as part of patomic-test-${kind} component
-            install(
-                FILES ${file_path}
-                COMPONENT patomic-test-${kind}
-                DESTINATION "${CMAKE_INSTALL_TESTDIR}/patomic/${kind}"
-                EXCLUDE_FROM_ALL
-            )
-
-            # install as part of patomic-test component
-            install(
-                FILES ${file_path}
-                COMPONENT patomic-test
-                DESTINATION "${CMAKE_INSTALL_TESTDIR}/patomic/${kind}"
-                EXCLUDE_FROM_ALL
-            )
-
-        endif()
-
-    endif()
-
-endfunction()
diff --git a/test/cmake/OptionVariables.cmake b/test/cmake/OptionVariables.cmake
index 81a860ad1..e05f963a2 100644
--- a/test/cmake/OptionVariables.cmake
+++ b/test/cmake/OptionVariables.cmake
@@ -1,14 +1,13 @@
 # ---- Options Summary ----
 
-# --------------------------------------------------------------------
-# | Option                               | Availability | Default    |
-# |======================================|==============|============|
-# | CMAKE_INSTALL_TESTDIR (unofficial)   | Always       | share/test |
-# |--------------------------------------|--------------|------------|
-# | PATOMIC_CREATE_TEST_TARGETS_MATCHING | Always       | ^(.*)$     |
-# | PATOMIC_WINDOWS_SET_CTEST_PATH_ENV   | Always       | ON         |
-# | PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE | Always       | OFF        |
-# --------------------------------------------------------------------
+# -----------------------------------------------------------------------
+# | Option                                | Availability   | Default    |
+# |=======================================|================|============|
+# | CMAKE_INSTALL_TESTDIR (unofficial)    | Always         | share/test |
+# |---------------------------------------|----------------|------------|
+# | PATOMIC_CREATE_TEST_TARGETS_MATCHING  | Always         | ^(.*)$     |
+# | PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV | Always (3.22+) | ON         |
+# -----------------------------------------------------------------------
 
 
 # ---- Test Install Directory ----
@@ -43,37 +42,24 @@ set(
 mark_as_advanced(PATOMIC_CREATE_TEST_TARGETS_MATCHING)
 
 
-# ---- Windows Tests Path ----
+# ---- Windows Tests Paths ----
 
-# By default we set PATH for tests run with CTest on Windows in order to prevent
-# linker errors.
-# Due to limitations in CMake, we can only completely override the PATH, rather
-# than prepend or append to it.
-# This gives users the option to disable this behaviour.
+# By default we prepend the PATH environment variable for tests run with CTest
+# on Windows with the directory paths of all its shared library dependencies.
+# This is done to prevent linker errors (because Windows doesn't support rpath).
 # This option has no effect when not running on Windows.
-option(
-    PATOMIC_WINDOWS_SET_CTEST_PATH_ENV
-    "Set PATH environment variable for tests when run CTest on Windows"
-    ON
-)
-mark_as_advanced(PATOMIC_WINDOWS_SET_CTEST_PATH_ENV)
-
-
-# ---- Windows Path File ----
-
-# On Windows we need to set PATH for tests but may not want to have the PATH be
-# completely overridden, like with PATOMIC_WINDOWS_SET_CTEST_PATH_ENV.
-# Instead we can generated a file per test kind that contains a string that can
-# be manually prepended to PATH before running tests, in order to ensure that
-# runtime dependencies can be found.
-# Most of the time we don't need this file (since CTest will take care of that
-# for us), so we don't generate it by default.
-# Additionally disabled by default because it contains potentially private
-# information about the target platform.
-# This option has no effect when not running on Windows.
-option(
-    PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE
-    "Create file with PATH environment variables for tests on Windows"
-    OFF
-)
-mark_as_advanced(PATOMIC_WINDOWS_CREATE_PATH_ENV_FILE)
+# This option requires CMake 3.22 or later.
+if("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.22.0")
+    option(
+        PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV
+        "Modify PATH environment variable for tests when run with CTest on Windows"
+        ON
+    )
+    mark_as_advanced(PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV)
+elseif(PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV)
+    message(
+        WARNING
+        "Option 'PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV' for 'patomic_test' requires CMake 3.22+, currently running ${CMAKE_VERSION}, option is disabled"
+    )
+    set(PATOMIC_WINDOWS_MODIFY_CTEST_PATH_ENV )
+endif()
\ No newline at end of file
diff --git a/test/cmake/WindowsDependenciesPath.cmake b/test/cmake/WindowsDependenciesPath.cmake
deleted file mode 100644
index c3cedb418..000000000
--- a/test/cmake/WindowsDependenciesPath.cmake
+++ /dev/null
@@ -1,46 +0,0 @@
-# ---- Windows Path Prefix ----
-
-# Windows doesn't support rpath, so when linking dynamically the libraries need
-# to either be in the same directory or in PATH.
-# This function sets a variable to a list of GENERATOR strings which resolve to
-# the paths of the linked dependencies.
-# See: https://docs.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-search-order
-# Usage: windows_deps_paths(<variable> <link-target>...)
-function(windows_deps_paths ARG_VAR)
-
-    # check that we're on Windows
-    if(NOT CMAKE_SYSTEM_NAME STREQUAL "Windows")
-        set(${ARG_VAR} "" PARENT_SCOPE)
-        return()
-    endif()
-
-    # create a list of paths to dependency shared library locations
-    set(paths "")
-    foreach(target IN LISTS ARGN)
-        # This will fail if passed a link option that isn't a target.
-        # This is intentional; don't do that.
-        # Instead, create an IMPORTED library, and set its target properties
-        # such as IMPORTED_LOCATION for the library (.a .so etc.) path and
-        # set INTERFACE_INCLUDE_DIRECTORIES to the directory containing any
-        # necessary header files.
-        get_target_property(type "${target}" TYPE)
-        if(type STREQUAL "SHARED_LIBRARY")
-            list(APPEND paths "$<TARGET_FILE_DIR:${target}>")
-        endif()
-    endforeach()
-
-    # remove duplicates
-    # makes generated file more human readable (if generated)
-    list(REMOVE_DUPLICATES paths)
-
-    # concatenate list into string with same format as PATH on Windows
-    set(path "")
-    set(glue "")
-    foreach(p IN LISTS paths)
-        set(path "${path}${glue}${p}")
-        set(glue "\;")  # backslash is important
-    endforeach()
-
-    # "return" string with PATH data
-    set(${ARG_VAR} "${path}" PARENT_SCOPE)
-endfunction()
diff --git a/test/improve_ctest_xml.py b/test/improve_ctest_xml.py
new file mode 100644
index 000000000..dd112670a
--- /dev/null
+++ b/test/improve_ctest_xml.py
@@ -0,0 +1,199 @@
+import argparse
+import xml.etree.ElementTree as ETree
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Optional
+
+
+@dataclass
+class ProgArgs:
+    input_path: Path
+    output_path: Path
+    triple: str
+
+
+def get_program_arguments() -> ProgArgs:
+    # define program arguments
+    parser = argparse.ArgumentParser(description="Tidy up CTest's JUnit XML to more closely match GTest output.")
+    parser.add_argument("-i", "--input", type=str, required=True, help="JUnit XML file generated by CTest")
+    parser.add_argument("-o", "--output", type=str, required=True, help="File path to write output into")
+    parser.add_argument("-t", "--triple", type=str, required=True, help="Platform identifier on which input file was generated from")
+
+    # obtain program arguments
+    args = parser.parse_args()
+    return ProgArgs(input_path=Path(args.input), output_path=Path(args.output), triple=args.triple)
+
+
+def _make_gtest_default_testsuite(suite_name: str) -> ETree.Element:
+    elem = ETree.Element("testsuite")
+    elem.attrib.update({
+        "name": suite_name,
+        "tests": "0",
+        "failures": "0",
+        "disabled": "0",
+        "skipped": "0",
+        "errors": "0",
+        "time": "0."
+    })
+    return elem
+
+
+def _make_gtest_testcase_result(suite_name: str, case_name: str, status: str, sysout: str) -> Optional[str]:
+    # test failed, possibly crashed
+    if status == "fail":
+        if f"[  FAILED  ] {suite_name}.{case_name}" in sysout:
+            return "failed"
+        else:
+            return "crashed"
+
+    # test was either skipped while running, or not run at all
+    elif status == "notrun":
+        if f"[  SKIPPED ] {suite_name}.{case_name}" in sysout:
+            return "skipped"
+        else:
+            return None
+
+    # test was run to completion
+    elif status == "run":
+        return "completed"
+
+    # should be unreachable
+    else:
+        raise ValueError(f"status attribute of {suite_name}.{case_name} has unknown value: {status}")
+
+
+def _add_gtest_testcase_to_testsuite(suite: ETree.Element, case: ETree.Element) -> ETree.Element:
+    # helper lambda
+    inc_attr = lambda elem, key: elem.set(key, str(int(elem.get(key)) + 1))
+
+    # increment time and tests count
+    inc_attr(suite, "tests")
+    new_time = str(float(suite.get("time")) + float(case.get("time")))
+    if '.' not in new_time:
+        new_time += '.'
+    suite.set("time", new_time)
+
+    # increment diagnostic counters
+    status: str = case.get("status")
+    result: Optional[str] = case.get("result")
+    if status == "run":
+        if result == "completed":
+            pass
+        elif result == "skipped":
+            inc_attr(suite, "skipped")
+        elif result == "failed":
+            inc_attr(suite, "failures")
+        elif result == "crashed":
+            inc_attr(suite, "errors")
+        else:
+            raise ValueError(f"result attribute of {suite.get('name')}.{case.get('name')} has unknown value: {result}")
+    elif status == "notrun":
+        inc_attr(suite, "disabled")
+    else:
+        raise ValueError(f"status attribute of {suite.get('name')}.{case.get('name')} has unknown value: {status}")
+
+    # attach case to suite
+    suite.append(case)
+    return suite
+
+
+def _parse_gtest_testsuites_from_ctest_testsuite(ctest_testsuite: ETree.Element) -> List[ETree.Element]:
+    # get all gtest testsuite names, mapped to their elements
+    gtest_suites: Dict[str, ETree.Element] = {}
+    for case in ctest_testsuite:
+
+        # all testcase element names are expected to be "Suite.Case" format
+        suite_name, case_name = case.get("name").split('.', maxsplit=1)
+        case.set("name", case_name)
+        case.set("classname", suite_name)
+
+        # make sure we recognise status
+        status: str = case.get("status")
+        if status not in ["run", "notrun", "fail"]:
+            raise ValueError(f"status attribute of {suite_name}.{case_name} has unknown value: {status}")
+
+        # get <system-out> sub-element for checking later
+        sysout: str = ""
+        all_elem_sysout = case.findall("system-out")
+        if len(all_elem_sysout) > 1:
+            raise ValueError(f"more than one <system-out> sub-element found in {suite_name}.{case_name}")
+        if len(all_elem_sysout) > 0:
+            sysout = "" if all_elem_sysout[0].text is None else str(all_elem_sysout[0].text)
+
+        # remove <system-out> sub-element if test runs with no issues
+        if status in "run" and sysout and not any(s in sysout.lower() for s in ["warn", "error", "fail"]):
+            case.remove(all_elem_sysout[0])
+
+        # set result to gtest values
+        result = _make_gtest_testcase_result(suite_name, case_name, status, sysout)
+        if result is not None:
+            case.set("result", result)
+        if status == "fail" or result == "skipped":
+            case.set("status", "run")
+
+        # populate dictionary
+        suite = gtest_suites.get(suite_name)
+        if suite is None:
+            suite = _make_gtest_default_testsuite(suite_name)
+        gtest_suites[suite_name] = _add_gtest_testcase_to_testsuite(suite, case)
+
+    # return all suites
+    return list(gtest_suites.values())
+
+
+def convert_ctest_root_to_gtest_root(triple: str, ctest_root: ETree.Element) -> ETree.Element:
+    # helper lambda
+    add_attr = lambda elem, from_, key: elem.set(key, str(int(elem.get(key)) + int(from_.get(key))))
+
+    # make gtest root element
+    gtest_root = ETree.Element("testsuites")
+    gtest_root.attrib.update({
+        "name": triple,
+        "tests": ctest_root.get("tests"),
+        "failures": "0",
+        "disabled": "0",
+        "skipped": "0",
+        "errors": "0",
+        "time": "0.",
+        "timestamp": ctest_root.get("timestamp")
+    })
+
+    # get attributes from all suites
+    gtest_suites = _parse_gtest_testsuites_from_ctest_testsuite(ctest_root)
+    for suite in gtest_suites:
+
+        # increment time
+        new_time = str(float(gtest_root.get("time")) + float(suite.get("time")))
+        if '.' not in new_time:
+            new_time += '.'
+        gtest_root.set("time", new_time)
+
+        # increment diagnostic counters
+        add_attr(gtest_root, suite, "failures")
+        add_attr(gtest_root, suite, "disabled")
+        add_attr(gtest_root, suite, "skipped")
+        add_attr(gtest_root, suite, "errors")
+
+        # add as sub-element
+        gtest_root.append(suite)
+
+    # return complete root
+    return gtest_root
+
+
+if __name__ == "__main__":
+
+    args = get_program_arguments()
+
+    # parse input file as xml
+    ctest_tree = ETree.parse(args.input_path)
+    ctest_root = ctest_tree.getroot()
+
+    # convert to gtest
+    gtest_root = convert_ctest_root_to_gtest_root(args.triple, ctest_root)
+    gtest_tree = ETree.ElementTree(gtest_root)
+
+    # write gtest xml
+    ETree.indent(gtest_tree)
+    gtest_tree.write(args.output_path, encoding="utf-8", xml_declaration=True)
diff --git a/test/include/CMakeLists.txt b/test/include/CMakeLists.txt
new file mode 100644
index 000000000..6205c9ebb
--- /dev/null
+++ b/test/include/CMakeLists.txt
@@ -0,0 +1,15 @@
+# fine with CML file here because include isn't installed for tests
+
+# create interface target that is automatically linked to all tests
+# this is solely so that IDEs understand that these are source files
+# include directories are set in test creation
+add_library(
+    patomic-test-include INTERFACE
+    # .
+)
+
+# require C++14 as minimum
+target_compile_features(
+    patomic-test-include INTERFACE
+    cxx_std_14
+)
diff --git a/test/kind/CMakeLists.txt b/test/kind/CMakeLists.txt
new file mode 100644
index 000000000..6a2917e2c
--- /dev/null
+++ b/test/kind/CMakeLists.txt
@@ -0,0 +1,20 @@
+# only add BTs if patomic target is available
+if(TARGET patomic::patomic)
+    add_subdirectory(bt)
+    message(STATUS "Enabled binary tests")
+else()
+    message(STATUS "Skipping binary tests; patomic target not available")
+endif()
+
+# always STs since they don't require patomic in any way
+add_subdirectory(st)
+message(STATUS "Enabled system tests")
+
+# only add UTs if patomic files are available
+# these are currently set by patomic before including this project
+if(PATOMIC_BINARY_DIR AND PATOMIC_SOURCE_DIR)
+    add_subdirectory(ut)
+    message(STATUS "Enabled unit tests")
+else()
+    message(STATUS "Skipping unit tests; not building as sub-project of patomic")
+endif()
diff --git a/test/bt/CMakeLists.txt b/test/kind/bt/CMakeLists.txt
similarity index 57%
rename from test/bt/CMakeLists.txt
rename to test/kind/bt/CMakeLists.txt
index 9cccd846e..cd4a859cb 100644
--- a/test/bt/CMakeLists.txt
+++ b/test/kind/bt/CMakeLists.txt
@@ -1,5 +1,4 @@
-include(../cmake/CreateTest.cmake)
-include(../cmake/WindowsDependenciesPath.cmake)
+include(../../cmake/CreateTest.cmake)
 
 
 # ---- Setup Tests ----
@@ -9,8 +8,3 @@ add_dependencies(patomic-test patomic-test-bt)
 
 create_bt(NAME example_add SOURCE example_add.cpp)
 create_bt(NAME example_sub SOURCE example_sub.cpp)
-
-
-# ---- Windows Path Issues ----
-
-create_test_win_deps_paths_file(BT)
diff --git a/test/bt/example_add.cpp b/test/kind/bt/example_add.cpp
similarity index 100%
rename from test/bt/example_add.cpp
rename to test/kind/bt/example_add.cpp
diff --git a/test/bt/example_sub.cpp b/test/kind/bt/example_sub.cpp
similarity index 100%
rename from test/bt/example_sub.cpp
rename to test/kind/bt/example_sub.cpp
diff --git a/test/kind/st/CMakeLists.txt b/test/kind/st/CMakeLists.txt
new file mode 100644
index 000000000..5312dc3a9
--- /dev/null
+++ b/test/kind/st/CMakeLists.txt
@@ -0,0 +1,10 @@
+include(../../cmake/CreateTest.cmake)
+
+
+# ---- Setup Tests ----
+
+add_custom_target(patomic-test-st)
+add_dependencies(patomic-test patomic-test-st)
+
+create_st(NAME check_asan SOURCE check_asan.cpp)
+create_st(NAME check_ubsan SOURCE check_ubsan.cpp)
diff --git a/test/kind/st/check_asan.cpp b/test/kind/st/check_asan.cpp
new file mode 100644
index 000000000..4ddf1c882
--- /dev/null
+++ b/test/kind/st/check_asan.cpp
@@ -0,0 +1,70 @@
+#include <gtest/gtest.h>
+#include <gtest/gtest-spi.h>
+
+
+class StAsan : public testing::Test
+{};
+
+
+TEST_F(StAsan, UseAfterFree)
+{
+#if PATOMIC_HAS_ASAN
+    EXPECT_FATAL_FAILURE({
+        int *p = new int(5);
+        volatile int val = *p;
+        delete p;
+
+        volatile int _ = *p;  // <-- use after free (intentional)
+    }, "asan");
+#endif
+}
+
+TEST_F(StAsan, HeapBufferOverflow)
+{
+#if PATOMIC_HAS_ASAN
+    EXPECT_FATAL_FAILURE({
+        int *arr = new int[100]{};
+        int i = 1;
+
+        volatile int _ = arr[100 + i];  // <-- buffer overflow (intentional)
+
+        delete[] arr;
+    }, "asan");
+#endif
+}
+
+TEST_F(StAsan, StackBufferOverflow)
+{
+#if PATOMIC_HAS_ASAN
+    EXPECT_NONFATAL_FAILURE({
+    EXPECT_FATAL_FAILURE({
+        int arr[100]{};
+        int i = 1;
+
+        volatile int _ = arr[100 + i];  // <-- buffer overflow (intentional)
+    }, "asan");
+#if defined(__clang__)
+    }, "Actual: 2");  // this is also caught by ubsan on clang once more
+#elif defined(__GNUG__)
+    }, "Actual: 3");  // this is also caught by ubsan on gcc twice more
+#endif
+#endif
+}
+
+TEST_F(StAsan, GlobalBufferOverflow)
+{
+#if PATOMIC_HAS_ASAN
+    EXPECT_NONFATAL_FAILURE({
+    EXPECT_FATAL_FAILURE({
+        static int arr[100]{};
+        int i = 1;
+
+        volatile int _ = arr[100 + i];  // <-- buffer overflow (intentional)
+    }, "asan");
+#if defined(__clang__)
+    }, "Actual: 2");  // this is also caught by ubsan on clang once more
+#elif defined(__GNUG__)
+    }, "Actual: 3");  // this is also caught by ubsan on gcc twice more
+#endif
+#endif
+}
diff --git a/test/kind/st/check_ubsan.cpp b/test/kind/st/check_ubsan.cpp
new file mode 100644
index 000000000..b97f683da
--- /dev/null
+++ b/test/kind/st/check_ubsan.cpp
@@ -0,0 +1,51 @@
+#include <gtest/gtest.h>
+#include <gtest/gtest-spi.h>
+
+#include <climits>
+#include <limits>
+
+
+class StUbsan : public testing::Test
+{};
+
+
+TEST_F(StUbsan, ShiftExponentTooLarge)
+{
+#if PATOMIC_HAS_UBSAN
+    EXPECT_FATAL_FAILURE({
+        volatile int _ = sizeof(int) * CHAR_BIT;
+        int x = 10;
+        int e = _;
+
+        _ = x << e;  // <-- shift exponent too large (intentional)
+    }, "ubsan");
+#endif
+}
+
+TEST_F(StUbsan, SignedIntegerOverflow)
+{
+#if PATOMIC_HAS_UBSAN
+    EXPECT_FATAL_FAILURE({
+        volatile int _ = 5;
+        int x = _;
+
+        x += INT_MAX;  // <-- signed integer overflow (intentional)
+
+        _ = x;
+    }, "ubsan");
+#endif
+}
+
+TEST_F(StUbsan, FloatCastOverflow)
+{
+#if PATOMIC_HAS_UBSAN && defined(__clang__)
+    EXPECT_FATAL_FAILURE({
+        volatile auto _ = std::numeric_limits<double>::max();
+        int x;
+
+        x = static_cast<int>(_);  // <-- float cast overflow (intentional)
+
+        _ = x;
+    }, "ubsan");
+#endif
+}
diff --git a/test/ut/CMakeLists.txt b/test/kind/ut/CMakeLists.txt
similarity index 65%
rename from test/ut/CMakeLists.txt
rename to test/kind/ut/CMakeLists.txt
index efdd8230e..a87c6a9ef 100644
--- a/test/ut/CMakeLists.txt
+++ b/test/kind/ut/CMakeLists.txt
@@ -1,5 +1,4 @@
-include(../cmake/CreateTest.cmake)
-include(../cmake/WindowsDependenciesPath.cmake)
+include(../../cmake/CreateTest.cmake)
 
 
 # ---- Setup Tests ----
@@ -9,8 +8,3 @@ add_dependencies(patomic-test patomic-test-ut)
 
 create_ut(NAME example_add SOURCE example_add.cpp "${PATOMIC_SOURCE_DIR}/src/patomic.c")
 create_ut(NAME example_sub SOURCE example_sub.cpp "${PATOMIC_SOURCE_DIR}/src/patomic.c")
-
-
-# ---- Windows Path Issues ----
-
-create_test_win_deps_paths_file(UT)
diff --git a/test/ut/example_add.cpp b/test/kind/ut/example_add.cpp
similarity index 100%
rename from test/ut/example_add.cpp
rename to test/kind/ut/example_add.cpp
diff --git a/test/ut/example_sub.cpp b/test/kind/ut/example_sub.cpp
similarity index 100%
rename from test/ut/example_sub.cpp
rename to test/kind/ut/example_sub.cpp
diff --git a/test/src/CMakeLists.txt b/test/src/CMakeLists.txt
new file mode 100644
index 000000000..2c59f583b
--- /dev/null
+++ b/test/src/CMakeLists.txt
@@ -0,0 +1,24 @@
+# create object target that is automatically linked to all tests
+add_library(
+    patomic-test-src OBJECT
+    # .
+    "sanitizer_error.cpp"
+    "sanitizer_options.cpp"
+)
+
+# add include directories
+target_include_directories(
+    patomic-test-src PRIVATE
+    "$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>"
+    "$<BUILD_INTERFACE:${GTEST_INCLUDE_DIRS}>"
+)
+
+# don't need to link against GTest or patomic:
+# - the test libraries already link against GTest which should be enough
+# - we shouldn't need anything from patomic here (or UTs would break)
+
+# require C++14 as minimum
+target_compile_features(
+    patomic-test-src PRIVATE
+    cxx_std_14
+)
diff --git a/test/src/sanitizer_error.cpp b/test/src/sanitizer_error.cpp
new file mode 100644
index 000000000..589aba567
--- /dev/null
+++ b/test/src/sanitizer_error.cpp
@@ -0,0 +1,30 @@
+#include <gtest/gtest.h>
+
+
+extern "C" {
+
+    /// @brief Called by asan when an error is detected.
+    /// @note  Asan may still abort after calling this function.
+    void
+    __asan_on_error()
+    {
+        GTEST_FAIL() << "Encountered an address sanitizer (asan) error";
+    }
+
+    /// @brief Called by tsan when an error is detected.
+    /// @note  Tsan may still abort after calling this function.
+    void
+    __tsan_on_report()
+    {
+        GTEST_FAIL() << "Encountered a thread sanitizer (tsan) error";
+    }
+
+    /// @brief Called by ubsan when an error is detected .
+    /// @note  Ubsan may still abort after calling this function.
+    void
+    __ubsan_on_report()
+    {
+        GTEST_FAIL() << "Encountered an undefined behaviour sanitizer (ubsan) error";
+    }
+
+}  // extern "C"
diff --git a/test/src/sanitizer_options.cpp b/test/src/sanitizer_options.cpp
new file mode 100644
index 000000000..a5af4d834
--- /dev/null
+++ b/test/src/sanitizer_options.cpp
@@ -0,0 +1,24 @@
+extern "C" {
+
+    /// @brief Called by asan to set default options.
+    const char *
+    __asan_default_options()
+    {
+        return "halt_on_error=0";
+    }
+
+    /// @brief Called by tsan to set default options.
+    const char *
+    __tsan_default_options()
+    {
+        return "";
+    }
+
+    /// @brief Called by ubsan to set default options.
+    const char *
+    __ubsan_default_options()
+    {
+        return "";
+    }
+
+}  // extern "C"