sets the name of the build dir, default is "build"
# --cmake-generator sets CMAKE_GENERATOR as used by cmake
# --target-macos-version sets the min os version - only used for macOS builds
-# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE
-# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir )
+# --profile writes an MSBuild performance summary and binary log, Windows only
+# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE
+# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir --profile)
+# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1)
# exitcode: 1 for failure, 3 for invalid arguments
# Read arguments
@@ -115,6 +117,10 @@ while [[ $# != 0 ]]; do
export CMAKE_GENERATOR=$1
shift
;;
+ '--profile')
+ BUILD_PROFILE=1
+ shift
+ ;;
'--target-macos-version')
shift
if [[ $# == 0 ]]; then
@@ -133,17 +139,21 @@ done
set -e
-# Setup
-./servatrice/check_schema_version.sh
-if [[ ! $BUILDTYPE ]]; then
+# Schema version consistency (only relevant to the server)
+if [[ ${MAKE_SERVER:-} == "1" ]]; then
+ ./servatrice/check_schema_version.sh
+fi
+
+# Use default values if unset
+if [[ -z $BUILDTYPE ]]; then
BUILDTYPE=Release
fi
-if [[ ! $BUILD_DIR ]]; then
+if [[ -z $BUILD_DIR ]]; then
BUILD_DIR="build"
fi
mkdir -p "$BUILD_DIR"
-if [[ $MAKE_COVERAGE ]]; then
+if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
# Resolved while the working directory is still the source root. $0 is relative to the
# directory the script was invoked from, so after the cd below it no longer resolves, and
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
@@ -158,49 +168,66 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10
# Add cmake flags
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
-if [[ $MAKE_SERVER ]]; then
+if [[ ${MAKE_SERVER:-} == "1" ]]; then
flags+=("-DWITH_SERVER=1")
fi
-if [[ $MAKE_NO_CLIENT ]]; then
+if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
fi
-if [[ $MAKE_TEST ]]; then
+if [[ ${MAKE_TEST:-} == "1" ]]; then
flags+=("-DTEST=1")
fi
-if [[ $MAKE_SANITIZE ]]; then
+if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
flags+=("-DENABLE_SANITIZERS=ON")
else
- # The release-package step re-runs cmake in the same build dir, so an
- # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak
- # into the shipped binaries (libasan/libubsan Depends in the DEB).
+ # Every leg states the flag explicitly rather than relying on the CMake
+ # default, so a build directory reused from a sanitized configuration can
+ # never leak libasan/libubsan into a shipped binary.
flags+=("-DENABLE_SANITIZERS=OFF")
fi
-if [[ $MAKE_COVERAGE ]]; then
+if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
flags+=("-DENABLE_COVERAGE=ON")
fi
-if [[ $USE_CCACHE ]]; then
+if [[ ${USE_CCACHE:-} == "1" ]]; then
flags+=("-DUSE_CCACHE=1")
- # PCH-aware caching is required or ccache refuses to cache any TU that
- # consumes a precompiled header, silently recompiling everything on every run.
- ccache --set-config sloppiness=pch_defines,time_macros
- if [[ $CCACHE_SIZE ]]; then
- # note, this setting persists after running the script
+ if [[ -n $CCACHE_SIZE ]]; then
+ # This setting persists after running the script
ccache --max-size "$CCACHE_SIZE"
fi
fi
-if [[ $PACKAGE_TYPE ]]; then
+if [[ -n $PACKAGE_TYPE ]]; then
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
fi
-if [[ $USE_VCPKG ]]; then
+if [[ ${USE_VCPKG:-} == "1" ]]; then
flags+=("-DUSE_VCPKG=1")
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi
+if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
+ # Release CI opts into the sentry-native SDK (default is OFF so from-source, distro and
+ # offline package builds never fetch it). Debug builds stay SDK-free so crashes from
+ # developer builds never reach Sentry.
+ flags+=("-DWITH_CRASH_REPORTING=ON")
+fi
+if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
+ # CI lowers the DWARF level so one build configuration fits its compiler-cache bound;
+ # see the option in CMakeLists.txt. Empty keeps the built-in flags.
+ flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL")
+fi
+if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
+ # Release-only: Debug builds stay DSN-free so crashes from developer builds
+ # never reach Sentry. Empty (local builds, forks without the secret, and non-tag
+ # CI builds, which never receive the secret) keeps today's mock fallback via the
+ # compiled-in default. CI passes the value from the SENTRY_DSN secret on tag
+ # Release builds only, so pre-release test artifacts never report to Sentry.
+ flags+=("-DCOCKATRICE_SENTRY_DSN=$COCKATRICE_SENTRY_DSN")
+fi
# Add cmake --build flags
buildflags=(--config "$BUILDTYPE")
function ccachestatsverbose() {
- # note, verbose only works on newer ccache, discard the error
+ ccache --version
+ # Verbose only works on newer ccache, discard the error
local got
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
echo "$got"
@@ -209,6 +236,22 @@ function ccachestatsverbose() {
fi
}
+function cpuinfo() {
+ # The number of cores decides how much build parallelism is useful; the hosted
+ # runners are not all sized alike, so read it off the machine instead of assuming.
+ case "$RUNNER_OS" in
+ Windows)
+ echo "cores: ${NUMBER_OF_PROCESSORS:-unknown}"
+ ;;
+ macOS)
+ echo "cores: $(sysctl -n hw.ncpu)"
+ ;;
+ *)
+ echo "cores: $(nproc 2>/dev/null || echo unknown)"
+ ;;
+ esac
+}
+
# Compile
if [[ $RUNNER_OS == macOS ]]; then
# QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action,
@@ -224,36 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then
# this works independent of the qt version as there should be only one version installed on the runner at a time
export QTDIR
- if [[ $TARGET_MACOS_VERSION ]]; then
- # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version
+ if [[ -n $TARGET_MACOS_VERSION ]]; then
+ # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version
flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION")
- # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
- # an easy way is to copy the x64-osx.cmake file and modify it
- triplets_dir="/tmp/cmake/triplets"
- triplet_version="custom-triplet"
- triplet_file="$triplets_dir/$triplet_version.cmake"
- arch=$(uname -m)
- if [[ $arch == x86_64 ]]; then
- arch="x64"
+ if [[ ${USE_VCPKG:-} == "1" ]]; then
+ # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
+ # An easy way is to copy the x64-osx.cmake file and modify it
+ triplets_dir="/tmp/cmake/triplets"
+ triplet_version="custom-triplet"
+ triplet_file="$triplets_dir/$triplet_version.cmake"
+ arch=$(uname -m)
+ if [[ $arch == x86_64 ]]; then
+ arch="x64"
+ fi
+ mkdir -p "$triplets_dir"
+ triplet_source="../vcpkg/triplets/$arch-osx.cmake"
+ if [[ ! -f "$triplet_source" ]]; then
+ triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
+ fi
+ cp "$triplet_source" "$triplet_file"
+ # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
+ # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
+ # produce identical binaries when they share the lowest deployment target, so they all
+ # build against 14 to keep one vcpkg cache scope instead of three.
+ # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
+ vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
+ echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
+ echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
+ flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
+ flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
+ flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
+ echo "::group::Generated triplet $triplet_file"
+ cat "$triplet_file"
+ echo "::endgroup::"
fi
- mkdir -p "$triplets_dir"
- triplet_source="../vcpkg/triplets/$arch-osx.cmake"
- if [[ ! -f "$triplet_source" ]]; then
- triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
- fi
- cp "$triplet_source" "$triplet_file"
- echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
- echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
- flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
- flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
- flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
- echo "::group::Generated triplet $triplet_file"
- cat "$triplet_file"
- echo "::endgroup::"
fi
- echo "::group::Signing Certificate"
+ echo "::group::Setup signing certificate"
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
@@ -268,18 +319,26 @@ if [[ $RUNNER_OS == macOS ]]; then
fi
echo "::endgroup::"
- if [[ $MAKE_PACKAGE ]]; then
+ if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
- # have hdiutil repeat the command 10 times in hope of success
+ # have hdiutil repeat the command 10 times in hope of success.
+ # The wrapper has to exit non-zero once it gives up: CPack reads that status to
+ # decide whether the disk image was created, so a zero exit turns a failed hdiutil
+ # into a much later, misleading "Problem copying the package" error that hides
+ # hdiutil's own message.
hdiutil_script="/tmp/hdiutil.sh"
# shellcheck disable=SC2016
echo '#!/bin/bash
+ attempts=10
i=0
- while ! hdiutil "$@"; do
- if (( ++i >= 10 )); then
- echo "Error: hdiutil failed $i times!" >&2
- break
+ while true; do
+ hdiutil "$@" && exit 0
+ rc=$?
+ if (( ++i >= attempts )); then
+ echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
+ exit "$rc"
fi
+ echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
chmod +x "$hdiutil_script"
@@ -287,38 +346,89 @@ if [[ $RUNNER_OS == macOS ]]; then
fi
elif [[ $RUNNER_OS == Windows ]]; then
- # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
- # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
- buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true)
+ if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then
+ # cmake only hands MSBuild a job count when one is asked for, and without it
+ # MSBuild builds a single node: one project at a time, whatever MTT does inside
+ # it. Ask for a node per core.
+ if [[ -z $BUILD_PARALLEL_LEVEL ]]; then
+ BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
+ fi
+ buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
+
+ # Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
+ # and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
+ # EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all
+ # the projects the nodes are building, instead of each node running its own
+ # cl.exe processes and /m and MTT multiplying into an oversubscribed machine.
+ buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL")
+ if [[ $BUILD_PROFILE ]]; then
+ # A performance summary says which projects and tasks cost what, the binary log
+ # keeps the full timeline; both are far too noisy to always produce. The files
+ # land next to the solution, i.e. in the build dir the workflow uploads. These
+ # join the MTT options above, which already carried the "--" separator.
+ buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog)
+ fi
+ else
+ # Ninja runs the compiler itself, so it neither takes the MSBuild switches
+ # (it rejects -p: options outright) nor a job count: it sizes its own pool
+ # for the cores it finds.
+ echo "ninja $(ninja --version)"
+ fi
fi
-if [[ $USE_CCACHE ]]; then
+if [[ ${USE_CCACHE:-} == "1" ]]; then
echo "::group::Show ccache stats"
ccachestatsverbose
echo "::endgroup::"
fi
-echo "::group::Configure cmake"
+# Configure CMake
+echo "::group::Generate build system"
cmake --version
-echo "Running cmake with flags: ${flags[*]}"
+echo "Running CMake configuration with the following flags: ${flags[*]}"
cmake .. "${flags[@]}"
echo "::endgroup::"
+if [[ ${USE_CCACHE:-} == "1" ]]; then
+ # PCH-aware caching is required or ccache refuses to cache any TU that consumes a
+ # precompiled header, silently recompiling everything on every run. The mtime and ctime
+ # entries cover files created during the build itself: the moc/uic/rcc output and the
+ # protobuf headers are generated after ccache starts, so without them every TU including
+ # one is read as a race and never cached. Checked-out sources are older than the compile
+ # that follows them and do not trip the check.
+ #
+ # This has to run after configure: CMakeLists.txt sets sloppiness=pch_defines,time_macros
+ # at configure time, and --set-config replaces the whole list rather than extending it, so
+ # setting it earlier would leave the build below with two entries. Re-asserting all four
+ # here is what actually reaches the compiler; the order is the point.
+ ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
+fi
+
+# Build
echo "::group::Build project"
-echo "Running cmake --build with flags: ${buildflags[*]}"
+cpuinfo
+echo "Running CMake with the following build flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}"
echo "::endgroup::"
-if [[ $USE_CCACHE ]]; then
- if [[ $CCACHE_EVICTION_AGE ]]; then
- echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
+if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
+ # Repeat the summary in the job log so the numbers are readable without
+ # downloading the binary log, which is hundreds of megabytes.
+ echo "::group::MSBuild performance summary"
+ cat msbuild-perf.log
+ echo "::endgroup::"
+fi
+
+if [[ ${USE_CCACHE:-} == "1" ]]; then
+ if [[ -n $CCACHE_EVICTION_AGE ]]; then
+ echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE"
ccache --evict-older-than "$CCACHE_EVICTION_AGE"
echo "::endgroup::"
fi
echo "::group::Show ccache stats again"
ccachestatsverbose
echo "::endgroup::"
-elif [[ $CCACHE_EVICTION_AGE ]]; then
+elif [[ -n $CCACHE_EVICTION_AGE ]]; then
echo "::error file=$0::ccache eviction is enabled while ccache is disabled!"
fi
@@ -335,9 +445,9 @@ if [[ $RUNNER_OS == macOS ]]; then
echo "::endgroup::"
fi
-if [[ $MAKE_TEST ]]; then
+if [[ ${MAKE_TEST:-} == "1" ]]; then
echo "::group::Run tests"
- if [[ $MAKE_SANITIZE ]]; then
+ if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
@@ -355,11 +465,12 @@ if [[ $MAKE_TEST ]]; then
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
+ ctest --version
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
echo "::endgroup::"
fi
-if [[ $MAKE_COVERAGE ]]; then
+if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
# and skipped rather than failing an otherwise green build.
echo "::group::Coverage report"
@@ -384,18 +495,221 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
fi
-if [[ $MAKE_INSTALL ]]; then
+if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then
+ echo "::group::Probe the installer's holder lookup"
+ # The working directory is still the build dir here: the package steps below are what
+ # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
+ # build/build. Both paths are relative to the dir the probe was built into.
+ probe="./installer-holder-probe.exe"
+ if [[ ! -f $probe ]]; then
+ echo "::error file=$0::installer-holder-probe.exe was not built"
+ exit 1
+ fi
+ # The probe exists to name a holder that a process-and-module scan cannot see: one
+ # that has a file open without having loaded it. Holding a temporary DLL with
+ # FileShare.None is exactly that case, so this asserts the reason the probe was
+ # added instead of only that the Restart Manager answers at all. It needs no NSIS
+ # and no packaging, so it runs on every Windows build that gets this far.
+ # The holder is a process of its own: the probe never names the process that started it,
+ # which is what keeps an in-place uninstaller from being named as a holder of the
+ # directory it is emptying. The same DLL held by the script that starts the probe
+ # therefore has to come back as nothing, and that is the second case below.
+ probe_check="./holder-probe-check.ps1"
+ cat >"$probe_check" <<'POWERSHELL'
+$ErrorActionPreference = "Stop"
+$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID"
+Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
+New-Item -ItemType Directory -Force $dir | Out-Null
+$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path
+
+function Invoke-Probe([string] $guardDir) {
+ $start = New-Object System.Diagnostics.ProcessStartInfo
+ $start.FileName = $probe
+ $start.UseShellExecute = $false
+ $start.RedirectStandardOutput = $true
+ $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir
+ $process = [System.Diagnostics.Process]::Start($start)
+ $read = $process.StandardOutput.ReadToEndAsync()
+ if (-not $process.WaitForExit(20000)) {
+ try { $process.Kill() } catch { }
+ throw "the holder probe did not finish within 20 s for $guardDir"
+ }
+ $output = $read.Result
+ $code = $process.ExitCode
+ $process.Dispose()
+ return @{ Code = $code; Output = $output }
+}
+
+function Wait-UntilLocked([string] $path) {
+ $deadline = (Get-Date).AddSeconds(15)
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $open = [IO.File]::Open($path, "Open", "ReadWrite", "None")
+ $open.Dispose()
+ } catch {
+ return
+ }
+ Start-Sleep -Milliseconds 100
+ }
+ throw "the holder never locked $path"
+}
+
+try {
+ $file = Join-Path $dir "locked.dll"
+ [IO.File]::WriteAllText($file, "x")
+
+ # The holder is a process of its own, not this script: the probe never names the process
+ # that started it (the case below asserts that), so a file held here would be left out on
+ # purpose and the check would pass for the wrong reason.
+ $holderScript = Join-Path $dir "hold.ps1"
+ Set-Content -LiteralPath $holderScript -Value @'
+param([string] $Path)
+$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None")
+Start-Sleep -Seconds 120
+'@
+ $powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
+ $holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @(
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"")
+ try {
+ Wait-UntilLocked $file
+ $locked = Invoke-Probe $dir
+ if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" }
+ if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") {
+ throw "the lookup did not name the process holding the file: $($locked.Output)"
+ }
+ } finally {
+ try { $holder.Kill() } catch { }
+ try { $holder.WaitForExit(5000) | Out-Null } catch { }
+ }
+
+ # The probe is started by the installer, or by an uninstaller that runs in place from the
+ # directory it is emptying, so the asking process holds files there itself. Naming it would
+ # ask the user to close the program that is asking - a question neither the close nor the
+ # end offer can answer, so the guard repeated it until the update was cancelled. A holder
+ # that is only the asking process has to come back as nothing.
+ $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None")
+ try {
+ $self = Invoke-Probe $dir
+ } finally {
+ $handle.Dispose()
+ }
+ if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" }
+ if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" }
+
+ # A payload executable that refuses to be replaced is running, whether or not this probe
+ # may name it: the Restart Manager does not report every mapping, and the image path of a
+ # process under another account cannot be read from here, so both process lookups can
+ # come back empty while the program is still there. Held here by the asking process, so
+ # that it cannot be named - the answer has to be "could not name it", never "nothing is
+ # holding it", which is what sent an update into an uninstall that could only fail
+ # halfway. Exit 3 is the code the guard answers by asking the user to close it and retry.
+ $image = Join-Path $dir "oracle.exe"
+ [IO.File]::WriteAllText($image, "x")
+ $handle = [IO.File]::Open($image, "Open", "ReadWrite", "None")
+ try {
+ $unnameable = Invoke-Probe $dir
+ } finally {
+ $handle.Dispose()
+ }
+ if ($unnameable.Code -ne 3) {
+ throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)"
+ }
+ if ($unnameable.Output -notmatch "oracle\.exe is in use") {
+ throw "the answer did not name the executable it could not replace: $($unnameable.Output)"
+ }
+
+ $empty = Join-Path $dir "empty"
+ New-Item -ItemType Directory -Force $empty | Out-Null
+ $nothing = Invoke-Probe $empty
+ if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" }
+ # A process running from the directory is a holder even when it has no file open: an
+ # instance holds its payload as mapped images, not as handles, and that is the state
+ # the guard used to miss - it passed, the purge failed, and the install ended in a
+ # warning with no offer to close and try again. The image is a copy of a native
+ # Windows program, so it starts without its usual directory next to it, and it carries
+ # one of the payload's own names because that is what the lookup names.
+ $copy = Join-Path $dir "cockatrice.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy
+ $running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $byImage = Invoke-Probe $dir
+ } finally {
+ try { $running.Kill() } catch { }
+ # Wait for the image to be released, or the cleanup below leaves the copy behind.
+ try { $running.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" }
+ if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") {
+ throw "the lookup did not name the process running from the directory: $($byImage.Output)"
+ }
+
+ # A program in a folder below the directory is not an instance of the installation, even
+ # when it carries a payload program's name: the install directory can be a folder the
+ # user keeps other applications in, each in its own subfolder, and naming one of those
+ # asked for a close the probe never sends and an end it refuses to perform, so the guard
+ # could only abort the update. A program that really does hold a payload file is still
+ # named, by the Restart Manager lookup exercised above.
+ $nested = Join-Path $dir "other"
+ New-Item -ItemType Directory -Force $nested | Out-Null
+ $nestedImage = Join-Path $nested "cockatrice.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $nestedImage
+ $nestedRunning = Start-Process -FilePath $nestedImage -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $inSubfolder = Invoke-Probe $dir
+ } finally {
+ try { $nestedRunning.Kill() } catch { }
+ try { $nestedRunning.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($inSubfolder.Code -ne 0) {
+ throw "expected exit 0 for a program in a folder below the directory, got $($inSubfolder.Code) : $($inSubfolder.Output)"
+ }
+
+ # A program the user keeps in the directory itself is not a payload program either: the
+ # guard ends only what the payload installs, and naming anything else asks for an answer
+ # that cannot free a single file.
+ $foreign = Join-Path $dir "tool.exe"
+ Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $foreign
+ $foreignRunning = Start-Process -FilePath $foreign -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru
+ try {
+ Start-Sleep -Milliseconds 1500
+ $ignored = Invoke-Probe $dir
+ } finally {
+ try { $foreignRunning.Kill() } catch { }
+ try { $foreignRunning.WaitForExit(5000) | Out-Null } catch { }
+ }
+ if ($ignored.Code -ne 0) {
+ throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)"
+ }
+ Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install"
+} finally {
+ Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
+}
+POWERSHELL
+ if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then
+ rm -f "$probe_check"
+ exit 1
+ fi
+ rm -f "$probe_check"
+ echo "::endgroup::"
+fi
+
+# Install
+if [[ ${MAKE_INSTALL:-} == "1" ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"
echo "::endgroup::"
fi
-if [[ $MAKE_PACKAGE ]]; then
+# Package
+if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
echo "::group::Create package"
+ cpack --version
cmake --build . --target package --config "$BUILDTYPE"
echo "::endgroup::"
- if [[ $PACKAGE_SUFFIX ]]; then
+ if [[ -n $PACKAGE_SUFFIX ]]; then
echo "::group::Update package name"
cd ..
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
diff --git a/.ci/docker.sh b/.ci/docker.sh
index 412ad6a1d..c78ba3c31 100644
--- a/.ci/docker.sh
+++ b/.ci/docker.sh
@@ -175,6 +175,17 @@ function RUN ()
args+=(--mount "type=bind,source=$GITHUB_OUTPUT,target=/gh_output")
args+=(--env "GITHUB_OUTPUT=/gh_output")
fi
+ # The container does not inherit host env vars: without this, WITH_CRASH_REPORTING=ON set in
+ # the workflow never reaches compile.sh inside the container, so the Linux packages silently
+ # build with crash reporting OFF while the libcurl packages in the Dockerfiles go unused.
+ if [[ ${WITH_CRASH_REPORTING:-} ]]; then
+ args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
+ fi
+ # Same reason: the DWARF level reaches compile.sh only if it is handed to the container,
+ # and without it CI would cache objects twice the size its bounds were measured for.
+ if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
+ args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}")
+ fi
# shellcheck disable=2086
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
return $?
diff --git a/.ci/release_template.md b/.ci/release_template.md
index 23b475150..8eb4c7ebd 100644
--- a/.ci/release_template.md
+++ b/.ci/release_template.md
@@ -21,8 +21,8 @@ Available pre-compiled binaries for installation:
• Ubuntu 24.04 LTSNoble Numbat
• Debian 13Trixie
• Debian 12Bookworm
+ • Fedora 45
• Fedora 44
- • Fedora 43General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)!Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository.
diff --git a/.dockerignore b/.dockerignore
index 8e0b69332..fc07b2b9d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,6 +2,6 @@ build/
.github/
.tx/
cockatrice/
-doc/
+docs/
oracle/
Dockerfile
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 75fbc59f1..75494dc3e 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -60,8 +60,10 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
+ ca-certificates \
cmake \
g++ \
+ libcurl4-openssl-dev \
libgl-dev \
liblzma-dev \
libmariadb-dev-compat \
diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 098aabc4f..90166ff03 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -15,7 +15,7 @@ on:
- '!**.md'
- '!.github/**'
- '!.tx/**'
- - '!doc/**'
+ - '!docs/**'
- '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt'
- 'vcpkg.json'
@@ -28,17 +28,39 @@ on:
- '!**.md'
- '!.github/**'
- '!.tx/**'
- - '!doc/**'
+ - '!docs/**'
- '.github/workflows/desktop-build.yml'
- 'CMakeLists.txt'
- 'vcpkg.json'
- 'vcpkg' # needed to match submodule bumps (gitlink)
+ # The legs the pull-request matrix leaves out run here instead, so the legacy distros and
+ # the slow macOS runner stay covered without gating a pull request on them. Tags still
+ # build every leg, because a tag is what ships.
+ schedule:
+ - cron: '23 3 * * *'
# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release)
concurrency:
- group: "${{ github.workflow }} @ ${{ github.ref_name }}"
+ # The event name is part of the group because a nightly run and a push run on master share
+ # refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg
+ # import, and a merge landing during the nightly would drop that night's coverage.
+ group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
cancel-in-progress: ${{ github.ref_type != 'tag' }}
+# Qt's online installer ships its base package as separate archives. Installing only the
+# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt
+# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs
+# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in
+# each leg's qt_modules.
+#
+# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing
+# here fails configure with "No suitable version of Qt was found" instead of at runtime:
+# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport;
+# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies
+# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs.
+env:
+ QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations
+
jobs:
configure:
name: Configure
@@ -46,6 +68,11 @@ jobs:
outputs:
tag: ${{ steps.configure.outputs.tag }}
sha: ${{ steps.configure.outputs.sha }}
+ # MAJ.MIN.PATCH[-beta[.N]] of a tag build; empty otherwise. Mirrors what
+ # cmake/getversion.cmake derives as PROJECT_VERSION, which the client bakes as its
+ # Sentry release (cmake/CrashReporting.cmake) — symbol uploads must use the same
+ # string so stack traces de-symbolicate against this build's binaries.
+ sentry_release: ${{ steps.configure.outputs.sentry_release }}
steps:
- name: "Configure"
@@ -56,6 +83,9 @@ jobs:
run: |
if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
+ # Tag format is YYYY-MM-DD-{Release|Development}-MAJ.MIN.PATCH[-beta[.N]];
+ # PROJECT_VERSION is the tail after the type (see cmake/getversion.cmake).
+ echo "sentry_release=$(sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-(Release|Development)-//' <<< "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT"
fi
echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
@@ -93,6 +123,36 @@ jobs:
--title "$release_name" \
--notes-file "$body_path"
+ vcpkg-baseline:
+ name: vcpkg baseline
+ runs-on: ubuntu-slim
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v7
+
+ # vcpkg-tool reads versions/baseline.json out of the commit named by
+ # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
+ # pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
+ # `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
+ # `git show` versions/baseline.json". That reads like a baseline or network
+ # problem, when the pointer is what has to move. Check the two agree here in
+ # seconds instead of on five build legs.
+ - name: "Check the submodule pointer against the manifest baseline"
+ shell: bash
+ run: |
+ pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
+ baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
+ if [[ -z "$pointer" || -z "$baseline" ]]; then
+ echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
+ exit 1
+ fi
+ if [[ "$pointer" != "$baseline" ]]; then
+ echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
+ echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
+ exit 1
+ fi
+ echo "vcpkg submodule and builtin-baseline agree on $baseline"
+
build-linux:
strategy:
fail-fast: false
@@ -103,6 +163,8 @@ jobs:
allow-failure: yes
package: skip # We are packaged in Arch already
+ debug_test: yes # see the note on Fedora 45 below
+ ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1
- distro: Servatrice_Debian
version: 12
@@ -110,88 +172,161 @@ jobs:
package: DEB
server_only: yes
test: skip
-
- - distro: Debian
- version: 12
-
- package: DEB
- test: skip # Running tests on all distros is superfluous
+ # 4.7 min and the server alone: nothing here for a compiler cache to save.
+ persist_ccache: 'false'
- distro: Debian
version: 13
package: DEB
+ ccache_size: 800M # Release plus the suite: 625 MB working set at -g1
+ # The single Fedora leg used to build Debug for the suite and Release for the
+ # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than
+ # Windows), so the package compile waited behind the Debug one. Two legs run in
+ # parallel and each keeps a cache that holds exactly one configuration.
+ #
+ # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions
+ # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug
+ # because only Debug passes -Werror, so it is where a new warning still fails.
- distro: Fedora
- version: 43
+ version: 45
+ variant: debug # separate image and ccache from the release leg below
- package: RPM
- test: skip # Running tests on all distros is superfluous
-
- - distro: Fedora
- version: 44
-
- package: RPM
-
- - distro: Ubuntu
- version: 24.04
-
- package: DEB
- test: skip # Running tests on all distros is superfluous
-
- - distro: Ubuntu
- version: 26.04
-
- package: DEB
-
- # The one leg that runs the suite under AddressSanitizer and
- # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
- # uninstrumented suite, so nothing is lost by instrumenting this one.
- sanitize: yes
-
- - distro: Ubuntu
- version: 26.04
- variant: cover # separate image and ccache, same distro
-
- # The one leg that runs instrumented for line coverage and publishes the report as
- # a workflow artifact. It is a separate leg rather than a second flag on the
- # sanitizer leg because the two instruments answer different questions: the
- # sanitizer leg reports what the run did wrong, the coverage leg reports what the
- # run never touched at all. Combining them would also slow both down. Packaging is
- # skipped because the leg above already publishes the DEB for this distro.
- coverage: yes
package: skip
+ debug_test: yes
+ ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1
+
+ - distro: Fedora
+ version: 45
+ variant: release # separate image and ccache from the debug leg above
+
+ package: RPM
+ test: skip # The debug leg above runs this distro's suite
+ ccache_size: 800M # Release plus the package: 614 MB of objects at -g1
+
+ - distro: Ubuntu
+ version: 26.04
+
+ package: DEB
+ test: skip # Debian 13 covers the uninstrumented suite on pull requests
+ # 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly
+ # matrix runs this distro's instrumented suite.
+ persist_ccache: 'false'
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
needs: configure
+ # The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two
+ # matrices, so nothing that reviews a change waits for a distro nobody is changing for.
+ if: github.event_name != 'schedule'
runs-on: ubuntu-latest # https://github.com/actions/runner-images
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
timeout-minutes: 70
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
- CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ # The bound is sized against the repository's 10 GB cache quota
+ # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy),
+ # not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB
+ # of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit
+ # layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry
+ # per analyzed commit, about 1.07 GB each, of which every one but the newest is dead.
+ # That is 10.30 GB against the 10 GB quota, so every save past this point costs some
+ # other entry, and least-recently-used order reaches the buildkit layers before the
+ # trap caches. prune-caches below keeps only the newest trap cache; the live total is
+ # then 7.1 GB, which is what leaves room for a Windows compiler cache.
+ #
+ # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with
+ # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit
+ # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls;
+ # the 700M bound on the same leg served 14%, because ccache fills to the limit and then
+ # evicts the entries the next build is about to ask for. A cache that cannot hold a
+ # whole configuration therefore buys almost nothing, and the quota goes to fewer legs
+ # at a size that holds one:
+ #
+ # Arch Debug + suite 680 MB working set 850M
+ # Debian 13 Release + suite 625 MB 800M
+ # Fedora 45 debug Debug + suite 660 MB 850M
+ # Fedora 45 release Release + package 614 MB 800M
+ #
+ # That is 3.3 GB of bounds, and with the fixed entries above (about 4.4 GB) it puts
+ # the quota at roughly 7.7 GB of 10 - so only legs worth their share get one. A leg
+ # keeps no cache when it duplicates another leg's coverage (Ubuntu 26.04 behind
+ # Debian 13's Release + suite; the macOS legs behind the Linux ones) or finishes well
+ # inside the Windows leg (Servatrice covers the server alone in 4.7 min). A macOS
+ # cache served 42% and saved about 32s on a 20 min leg that is already shorter than
+ # Windows, so that bound is better spent here. The four Linux legs that stay compile
+ # the client on the toolchains a change can break and are the ones a cache shortens
+ # by the most.
+ # Measured job totals on master 2026-11-04 also show the ceiling this leaves:
+ # Windows 10 is 29.5 min and compiles for 23.7 of it with no compiler cache at all, so
+ # Windows still sets the pull-request wall clock no matter how warm these legs are
+ # (see build-vcpkg).
+ #
+ # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13
+ # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release
+ # 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had
+ # invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G
+ # run on the same day measured one configuration at 680 MB (Arch) and 625 MB
+ # (Debian 13), so those bounds already hold the working set with ~170 MB of slack for
+ # commit-to-commit churn, and raising them buys hit rate the numbers do not show a
+ # need for. macOS 14's 800M entry is dropped because persistence buys 32s there; the
+ # quota stays free for the legs whose bounds would actually miss.
+ #
+ # Re-add a leg only with a bound that holds its whole configuration: a smaller one is
+ # worse than none.
+ #
+ # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix.
+ CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
+ # Debug information dominates a cached object: with the built-in flags one
+ # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to
+ # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and
+ # one configuration halves. Empty on tags and on the nightly legs, which ship binaries
+ # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and
+ # is slower, which is accepted because a tag is rare and only pull requests gate.
+ COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }}
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
IMAGE_SUFFIX: ${{ matrix.variant }}
+ # Which build the suite runs in, decided once per leg here so the steps' if: conditions
+ # and their shells cannot drift apart. A step's own env is not visible to its if:, which
+ # is why these live at job level rather than on the release step.
+ # DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two
+ # newest distro toolchains, which only fail on a warning in Debug.
+ # RUN_TESTS every other leg that tests runs the suite against the release build it may
+ # also package, so testing and packaging cost one compile instead of two.
+ DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
+ RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
+ # Whether this leg restores and saves its compiler cache. Not every pull-request leg
+ # keeps one: a cache only pays for itself when its bound holds a whole configuration
+ # (see the sizing comment above), and a leg that duplicates another leg's coverage
+ # spends quota without adding any. The nightly-only legs keep none either. Fedora 45
+ # states it per leg: its two configurations have their own job and key, so neither
+ # bound is shared with a build whose objects it would evict.
+ PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
- steps:
+ # Shared with build-linux-nightly so the two leg sets run the same steps.
+ steps: &linux-steps
- name: "Checkout"
uses: actions/checkout@v7
- name: "Restore compiler cache (ccache)"
id: restore_ccache
+ if: env.PERSIST_CCACHE == 'true'
uses: actions/cache/restore@v6
with:
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
- path: ${{ env.CACHE }}
+ path: ${{ env.CACHE }}/.ccache
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
shell: bash
run: source .ci/docker.sh --build
+ # The instrumented legs and the two newest distro toolchains build and test Debug; every
+ # other leg runs its suite against the release build below, so testing and packaging cost
+ # one compile instead of two.
- name: "Build debug and test"
- if: matrix.test != 'skip'
+ if: env.DEBUG_TEST == 'true'
shell: bash
env:
sanitize: '${{ matrix.sanitize }}'
@@ -225,34 +360,113 @@ jobs:
- name: "Upload coverage artifact"
if: matrix.coverage == 'yes'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage/
if-no-files-found: warn
- - name: "Build release package"
+ # One build serves both purposes: the suite runs against the same configuration that
+ # gets packaged, so a packaging leg no longer pays for a full Debug compile it then
+ # throws away. macOS and Windows have always tested their release build.
+ #
+ # Instrumented and debug-test legs reach this step only when they also package; their
+ # suite already ran in the debug step above, so the tests are not repeated here.
+ - name: "Build release, test and package"
id: build
- if: matrix.package != 'skip'
+ if: matrix.package != 'skip' || env.RUN_TESTS == 'true'
shell: bash
env:
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
package: '${{ matrix.package }}'
server_only: '${{ matrix.server_only }}'
+ # Only a leg that packages builds the shipping configuration; a release build that
+ # only runs tests stays free of the crash reporting SDK.
+ WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }}
+ # Tag builds only: artifacts from master pushes and PR branches must not report to
+ # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
+ # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
+ # and the symbol upload below just like full releases.
+ COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }}
run: |
source .ci/docker.sh
args=()
[[ $server_only == yes ]] && args+=(--no-client)
+ [[ $RUN_TESTS == true ]] && args+=(--test)
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
args+=(--suffix "$SUFFIX")
+ # The container inherits no environment, so the DSN rides in explicitly. -e without
+ # =value passes it through (possibly empty); compile.sh only consumes it for Release
+ # configures, and empty keeps the mock fallback.
+ export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN"
- RUN --server --release --package "$package" "${args[@]}"
+ # --package takes an optional type, so the flag and its argument have to travel
+ # together or the type would become the next flag.
+ package_args=()
+ [[ -n $package && $package != skip ]] && package_args+=(--package "$package")
+ # compile.sh runs the tests before the package target, so a failing suite stops the
+ # leg before anything is built into an installer.
+ RUN --server --release "${package_args[@]}" "${args[@]}"
+
+ - name: "Install sentry-cli"
+ if: matrix.package != 'skip' && needs.configure.outputs.tag != null
+ # Downloads from Sentry's release registry: a Sentry outage or a broken install under
+ # Git Bash must not take down the release assets below.
+ continue-on-error: true
+ shell: bash
+ env:
+ SENTRY_CLI_VERSION: '3.8.0'
+ run: |
+ export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
+ curl -sL https://sentry.io/get-cli/ | bash
+ echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
+
+ - name: "Upload debug symbols to Sentry"
+ # Like the DSN above, this runs for every tag — beta (Development) tags included.
+ if: matrix.package != 'skip' && needs.configure.outputs.tag != null
+ # A Sentry outage or rotated token must not take down the release assets below.
+ continue-on-error: true
+ shell: bash
+ env:
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ run: |
+ paths=()
+ # The binaries only, not the directories: a directory makes sentry-cli walk the
+ # whole build tree, where it finds (and uploads) object files and other fragments
+ # that can never match a stack frame. The packaged copy is split and stripped by
+ # cpack, but keeps this binary's GNU build-id, which is how Sentry matches it.
+ for app in cockatrice oracle servatrice; do
+ binary="build/$app/$app"
+ [[ -f $binary ]] && paths+=("$binary")
+ done
+ if [[ ${#paths[@]} == 0 ]]; then
+ # Fail loudly instead of skipping: see the Windows/macOS leg below — this step
+ # is the only way symbols reach Sentry and `continue-on-error` keeps the job
+ # green either way, so a silent skip looks exactly like a successful upload.
+ echo "::error::No debug symbol files found; nothing would be uploaded"
+ exit 1
+ fi
+ # Project slug on Sentry; must match the project the baked DSN points at.
+ # No --release here: `debug-files upload` has no such option (sentry-cli errors on
+ # the unknown flag and uploads nothing), and debug information files are matched to
+ # events by debug ID via the event's debug_meta, not by release. The release grouping
+ # comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
+ sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
+
+ # Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save
+ # would replace the objects the next master build restores, and a PR that compiles a
+ # different configuration would evict the warm set for everyone. Tags build with full
+ # DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1
+ # set if it shared the master scope; it does not, because the save is master-only. The
+ # nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason:
+ # a schedule shares refs/heads/master with the push.
+ #
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- name: "Delete remote compiler cache (ccache)"
- if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
+ if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@@ -263,11 +477,11 @@ jobs:
fi
- name: "Save updated compiler cache (ccache)"
- if: github.ref == 'refs/heads/master'
+ if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true'
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
- path: ${{ env.CACHE }}
+ path: ${{ env.CACHE }}/.ccache
- name: "Upload artifact"
id: upload_artifact
@@ -305,33 +519,109 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
+ build-linux-nightly:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - distro: Debian
+ version: 12 # previous stable, still covered by the nightly
+
+ package: DEB
+ test: skip # Running tests on all distros is superfluous
+
+ - distro: Fedora
+ version: 44 # previous release, still covered by the nightly
+
+ package: RPM
+ test: skip # Running tests on all distros is superfluous
+
+ - distro: Ubuntu
+ version: 24.04 # previous LTS, still covered by the nightly
+
+ package: DEB
+ test: skip # Running tests on all distros is superfluous
+
+ # The one leg that runs the suite under AddressSanitizer and
+ # UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is
+ # not on the pull-request critical path (Windows sets that), and its 1.2G cache
+ # was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep
+ # covering the uninstrumented suite on pull requests, so nothing is lost there.
+ #
+ # It packages nothing. A sanitized Debug build is not something to ship; the
+ # pull-request Ubuntu 26.04 leg publishes this distro's DEB.
+ - distro: Ubuntu
+ version: 26.04
+ variant: sanitize # separate image, same distro as the coverage leg below
+
+ package: skip
+ sanitize: yes
+ # A sanitizer finding must not redden the nightly run: vcpkg-cache-import only
+ # imports the archives of a run whose conclusion is success. The job itself
+ # still reports failure.
+ allow-failure: yes
+ # Scratch only (nightly legs persist no cache); sized so the instrumented
+ # objects, which are larger than the 614 MB measured, do not thrash mid-build.
+ ccache_size: 1200M
+
+ # The one leg that runs instrumented for line coverage and publishes the report as
+ # a workflow artifact. It stays a separate leg rather than a second flag on the
+ # sanitizer leg because the two instruments answer different questions: the
+ # sanitizer leg reports what the run did wrong, the coverage leg reports what the
+ # run never touched at all. Combining them would also slow both down.
+ #
+ # It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot
+ # fail a build, and nothing consumes the report per change, which made it an
+ # artifact nobody read on every pull request. On the default branch it becomes a
+ # trend instead. Image and ccache keys come from distro, version and variant, so
+ # they are unchanged and the warm cache that leg built still matches.
+ - distro: Ubuntu
+ version: 26.04
+ variant: cover # separate image and ccache, same distro
+
+ coverage: yes
+ package: skip
+ # A report must not redden the nightly run: vcpkg-cache-import only imports the
+ # archives of a run whose conclusion is success.
+ allow-failure: yes
+
+ name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
+ needs: configure
+ # A nightly and a tag build cover the legs a pull request or a merge does not wait for:
+ # the previous release of each distro, and the instrumented legs.
+ if: github.event_name == 'schedule' || github.ref_type == 'tag'
+ runs-on: ubuntu-latest # https://github.com/actions/runner-images
+ continue-on-error: ${{ matrix.allow-failure == 'yes' }}
+ timeout-minutes: 70
+ env:
+ CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
+ CCACHE_EVICTION_AGE: 7d
+ # These legs do not keep a compiler cache: they run once a day (and on tags) and gate
+ # nothing, so their share of the 10 GB quota goes to the pull-request legs instead.
+ # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request
+ # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
+ PERSIST_CCACHE: 'false'
+ CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }}
+ CMAKE_GENERATOR: 'Ninja'
+ NAME: ${{ matrix.distro }}${{ matrix.version }}
+ IMAGE_SUFFIX: ${{ matrix.variant }}
+ # The shared steps below gate on these, so they have to be derived here too. Left
+ # undefined they silently skipped the build the leg exists for: the coverage leg
+ # found build/ empty and its report collection failed on every nightly and tag run.
+ # Same derivation as the pull request job above.
+ DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
+ RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
+ steps: *linux-steps
+
build-vcpkg:
strategy:
fail-fast: false
matrix:
include:
- os: macOS
- target: 13 # EOL 2025-09-15
- runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
- # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
+ target: 14 # EOL 2026-09-14
+ runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
- ccache_eviction_age: 7d
- cmake_generator: Ninja
- make_package: 1
- override_target: 13
- package_suffix: "-macOS13_Intel"
- qt_version: 6.11.*
- qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
- soc: Intel
- type: Release
- use_ccache: 1
- xcode: "26.3"
-
- - os: macOS
- target: 14 # EOL 2026-??
- runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
-
- ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 14
@@ -340,14 +630,14 @@ jobs:
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Release
- use_ccache: 1
- xcode: "26.6"
+ vcpkg_deployment_target: 14
+ vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
+ vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
- os: macOS
- target: 15
- runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
+ target: 15 # EOL 2027-??
+ runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
- ccache_eviction_age: 7d
cmake_generator: Ninja
make_package: 1
override_target: 15
@@ -356,43 +646,56 @@ jobs:
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Release
- use_ccache: 1
- xcode: "26.6"
+ vcpkg_deployment_target: 14
+ vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
- os: macOS
- target: 26
- runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
+ target: 27
+ runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
- ccache_eviction_age: 7d
cmake_generator: Ninja
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple
type: Debug
- use_ccache: 1
- xcode: "26.6"
+ vcpkg_deployment_target: 14
+ vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
- os: Windows
target: 10
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
- cmake_generator: "Visual Studio 18 2026"
- cmake_generator_platform: x64
+ # Ninja builds the whole dependency graph at once instead of one
+ # MSBuild node at a time, runs the compiler itself, and matches the
+ # Linux and macOS legs. Its median build phase measured ~17 min
+ # against ~21 for the Visual Studio generator (shared 4-core hosts
+ # swing either by minutes, so read medians, not single runs).
+ cmake_generator: Ninja
make_package: 1
package_suffix: "-Win10"
qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release
+ # Scope is named after the vs2026 toolchain, not the generator: the
+ # ABI hash covers cl.exe, which is identical, so this leg keeps
+ # restoring the warm archives. Renaming would orphan them.
+ vcpkg_scope: 'vcpkg/windows10-x64-vs2026/'
+ vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
- needs: configure
+ needs: [configure, vcpkg-baseline]
+ # The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs.
+ if: github.event_name != 'schedule'
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:
- CCACHE_DIR: ${{ github.workspace }}/.cache/
- CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ # No leg in this job keeps a compiler cache: neither the macOS legs nor the Windows
+ # leg can fill one across runs. See the note on the removed macOS ccache steps below.
+ # MSVC has no -g level, so the Windows leg does not ask for one.
+ COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }}
- steps:
+ # Shared with build-vcpkg-nightly so the two leg sets run the same steps.
+ steps: &vcpkg-steps
- name: "Checkout"
uses: actions/checkout@v7
with:
@@ -404,17 +707,11 @@ jobs:
with:
msbuild-architecture: x64
- - name: "[macOS] Setup ccache"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1
- run: brew install ccache
-
- - name: "[macOS] Restore compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1
- id: restore_ccache
- uses: actions/cache/restore@v6
- with:
- key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
- path: ${{ env.CCACHE_DIR }}
+ # No macOS leg runs ccache. A restored macOS cache served 42% of calls and saved
+ # about 32s on a 20 min leg that is already shorter than the Windows leg, which did
+ # not pay for its 800M slice of the quota; with nothing restored, the remaining
+ # intra-run hits are 3% and the hashing is pure overhead on ~1160 compiles, so the
+ # steps that installed and restored ccache are gone rather than merely disabled.
- name: "[macOS] Install aqtinstall"
if: matrix.os == 'macOS'
@@ -434,7 +731,7 @@ jobs:
id: restore_qt
uses: actions/cache/restore@v6
with:
- key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin
+ key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin
path: ${{ github.workspace }}/Qt
# Using jurplel/install-qt-action to install Qt without using brew
@@ -443,6 +740,7 @@ jobs:
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: jurplel/install-qt-action@v4
with:
+ archives: ${{ env.QT_ARCHIVES }}
cache: false
# cache-key-prefix: Qt
dir: ${{ github.workspace }} # thinning script depends on this location
@@ -466,6 +764,7 @@ jobs:
with:
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
aqtsource: git+https://github.com/miurahr/aqtinstall.git
+ archives: ${{ env.QT_ARCHIVES }}
cache: ${{ github.ref == 'refs/heads/master' }}
cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }}
@@ -474,24 +773,59 @@ jobs:
- name: "[Windows] Install NSIS"
if: matrix.os == 'Windows'
shell: bash
- run: choco install nsis
+ run: choco install nsis --no-progress
+
+ # Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
+ - name: "[Windows] Set up MSVC environment"
+ if: matrix.os == 'Windows'
+ uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0
+ with:
+ arch: x64
- name: "Setup vcpkg cache"
id: vcpkg-cache
uses: TAServers/vcpkg-cache@v3
with:
+ # Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
+ # target and toolchain. The action restores every entry it finds under the
+ # prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/
+ # namespace every leg downloads all 66 archives of all 5 legs on every run.
+ # Each of those restores also refreshes the entry's last access date, which
+ # is why a dead ABI generation is never evicted by age. Naming the scope is
+ # what makes those generations identifiable, and prune-caches below able to
+ # delete them.
+ prefix: ${{ matrix.vcpkg_scope }}
token: ${{ secrets.GITHUB_TOKEN }}
+ # The action only creates its directory when the prefix matched at least one
+ # entry, and it reads that directory unguarded when it saves at the end of the
+ # job. A scope nobody has written to yet therefore leaves the directory missing
+ # and fails the job, which is exactly the state every scope starts in. Make the
+ # directory up front so an empty scope is a cache miss instead of an error.
+ # always() covers the cache step itself failing: the action's post save still runs
+ # then, and it should report the real error instead of ENOENT. It cannot rescue a
+ # failure before the cache step, since a post step only runs for an action whose
+ # main step did. The fallback keeps this guard from failing when the cache step was
+ # skipped and left no path output.
+ - name: "Create vcpkg cache directory"
+ if: always()
+ shell: bash
+ env:
+ CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
+ run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}"
+
# Uses environment variables, see compile.sh for more details
- name: "Build Cockatrice"
id: build
shell: bash
env:
BUILDTYPE: '${{ matrix.type }}'
- CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
- CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
- DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
+ # Tag builds only: artifacts from master pushes and PR branches must not report to
+ # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
+ # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
+ # and the symbol upload below just like full releases.
+ COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }}
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
@@ -500,12 +834,138 @@ jobs:
MAKE_PACKAGE: '${{ matrix.make_package }}'
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }}
- USE_CCACHE: ${{ matrix.use_ccache }}
+ # Only pushes to the default branch may write the shared binary cache: the
+ # archives a run compiles are only as trustworthy as the code they were
+ # built from, and only merged code may populate the scope that master and
+ # release builds restore and link. Pull requests (same-repository ones
+ # included) restore the shared cache but never write it; with the source
+ # left at read the collect step below finds nothing on PRs, so no
+ # pull-request-scoped entry is ever saved that no later run could restore.
+ # Forks pushing to their own default branch resolve github.ref against
+ # their own repository, so they keep a working cache of their own there.
VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph
+ VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
+ # Release legs compile the sentry-native SDK; Debug legs stay SDK-free.
+ WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
run: .ci/compile.sh --server --test --vcpkg
+ - name: "Install sentry-cli"
+ if: needs.configure.outputs.tag != null && matrix.make_package == '1'
+ # Same as the Linux leg above: the install downloads from Sentry's registry, so a
+ # failure here must not block the release upload below.
+ continue-on-error: true
+ shell: bash
+ env:
+ SENTRY_CLI_VERSION: '3.8.0'
+ run: |
+ export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli"
+ curl -sL https://sentry.io/get-cli/ | bash
+ echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH"
+
+ - name: "Upload debug symbols to Sentry"
+ # Like the DSN above, this runs for every tag — beta (Development) tags included.
+ if: needs.configure.outputs.tag != null && matrix.make_package == '1'
+ # A Sentry outage or rotated token must not take down the release assets below.
+ continue-on-error: true
+ shell: bash
+ env:
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ run: |
+ paths=()
+ if [[ '${{ matrix.os }}' == 'Windows' ]]; then
+ # Top-level CMakeLists.txt links /DEBUG even in release, so the linker PDBs
+ # land next to the binaries. Ninja is single-config, so that is
+ # build// and NOT build//Release/ — the same globs the symbol
+ # artifact step below uses.
+ #
+ # The executables are uploaded as well, in the same invocation. On 64-bit
+ # Windows the Call Frame Information (.pdata/.xdata) lives in the PE and is
+ # not copied into the PDB, so a PDB on its own leaves Sentry reporting
+ # "Stack Unwinding: Missing" for the image. sentry-cli also needs to see the
+ # PE and its PDB together: only then does it associate the PE's code
+ # identifier with the PDB's debug identifier.
+ shopt -s nullglob
+ for target in cockatrice oracle servatrice; do
+ paths+=("build/$target"/*.exe "build/$target"/*.pdb)
+ done
+ shopt -u nullglob
+
+ # Qt's own runtime DLLs, taken from required-runtime.txt — the manifest
+ # .ci/compile.sh asserts the installer against — so the set uploaded is
+ # the set an install actually carries. The PE is the only carrier of
+ # unwind information for a 64-bit image, and sending it in the same
+ # invocation as a PDB is what lets sentry-cli associate the PE's code
+ # identifier with the PDB's debug identifier. Qt ships no PDBs with the
+ # SDK, so Qt frames resolve through the export table: names, no lines.
+ qt_dir=
+ qt_core_dir=
+ # Existence guards matter: the step runs under `bash -eo pipefail`, so a
+ # redirection-less sed over a missing file would abort the step outright
+ # and continue-on-error would hide it.
+ if [[ -f build/CMakeCache.txt ]]; then
+ qt_core_dir=$(sed -n 's/^Qt6Core_DIR:PATH=//p' build/CMakeCache.txt | head -1)
+ fi
+ if [[ -n $qt_core_dir ]]; then
+ qt_dir=$(dirname "$(dirname "$(dirname "$qt_core_dir")")")
+ fi
+ if [[ -z $qt_dir || ! -d $qt_dir ]]; then
+ # Warnings, not failures: the upload of our own binaries is what this
+ # step is for, and continue-on-error already means an upload failure
+ # cannot be told apart from a successful one.
+ echo "::warning::Qt runtime directory not resolved from build/CMakeCache.txt; Qt images get no unwind information"
+ elif [[ ! -f build/required-runtime.txt ]]; then
+ echo "::warning::build/required-runtime.txt is missing; cannot tell which Qt DLLs ship"
+ else
+ while IFS= read -r runtime; do
+ name=${runtime##*/}
+ if [[ $runtime == */* ]]; then
+ # A Qt plugin: the install root keeps it as plugins//.
+ # `|| true`: an unmatched glob makes ls exit non-zero, and pipefail
+ # turns that into a step abort.
+ runtime_path=$(ls "$qt_dir/plugins"/*/"$name" 2>/dev/null | head -1) || true
+ else
+ runtime_path="$qt_dir/bin/$name"
+ fi
+ if [[ -f ${runtime_path:-} ]]; then
+ paths+=("$runtime_path")
+ else
+ echo "::warning::shipped Qt runtime $runtime not found under $qt_dir"
+ fi
+ done < <(grep -E '^(Qt6[^/]*\.dll|Plugins/)' build/required-runtime.txt)
+ fi
+ else
+ # Release builds carry DWARF (-g, kept by CrashReporting.cmake); macOS keeps its
+ # debug info in dSYM bundles, so materialize them before uploading.
+ for app in cockatrice oracle servatrice; do
+ binary="build/$app/$app.app/Contents/MacOS/$app"
+ if [[ -f $binary ]]; then
+ if [[ '${{ matrix.os }}' == 'macOS' ]]; then
+ dsymutil "$binary" -o "$binary.dSYM"
+ paths+=("$binary.dSYM")
+ # The dSYM holds the debug information, but the unwind information
+ # Sentry needs to walk a minidump stack lives in the Mach-O itself.
+ paths+=("$binary")
+ else
+ paths+=("$binary")
+ fi
+ fi
+ done
+ fi
+ if [[ ${#paths[@]} == 0 ]]; then
+ # Fail loudly instead of skipping: this step is the only way symbols reach
+ # Sentry, and `continue-on-error` keeps the job green either way, so a
+ # silent skip is indistinguishable from a successful upload. It also hid a
+ # wrong Windows path (build//Release, which Ninja never creates).
+ echo "::error::No debug symbol files found; nothing would be uploaded"
+ exit 1
+ fi
+ # Project slug on Sentry; must match the project the baked DSN points at.
+ # No --release: see the Linux leg above — the flag does not exist on
+ # `debug-files upload` and DIFs are matched by debug ID, not release.
+ sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
+
# CPack reports a failed makensis run as "Problem running NSIS command"
# plus a path to NSISOutput.log, and that log lives only on the runner, so
# the actual compile error is otherwise nowhere in the workflow output.
@@ -517,24 +977,62 @@ jobs:
shell: bash
run: find . -name NSISOutput.log -exec cat {} +
- # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
- - name: "[macOS] Delete remote compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
- continue-on-error: true
+ # A master run hands over the archives it had to build itself so that
+ # vcpkg-cache-import.yml, which runs in the default branch scope, can save them
+ # where later runs can restore them. Pull requests hand over nothing: their cache
+ # entries would be scoped to refs/pull//merge and stay unreadable for every
+ # other run, so writing them only burns the repository's quota.
+ # vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope
+ # by design, and all three rebuild the same archives, so uploading from each of them
+ # would publish three identical 269 MB artifacts under one artifact name and make
+ # the importer spawn a save job per copy.
+ - name: "Collect new vcpkg entries"
+ id: vcpkg_new
+ if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1
+ shell: bash
env:
- CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
+ CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
GH_TOKEN: ${{ github.token }}
+ VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
run: |
- if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then
- echo "Cache deleted successfully"
- fi
+ # The archive name is the ABI hash, so a missing key is exactly what vcpkg had
+ # to compile from source in this job.
+ gh api --paginate \
+ "repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \
+ --jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt"
+ find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; |
+ sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt"
+ comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt"
- - name: "[macOS] Cache updated compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master'
- uses: actions/cache/save@v6
+ mkdir -p "$RUNNER_TEMP/vcpkg-new"
+ : >"$RUNNER_TEMP/vcpkg-new/keys.txt"
+ while read -r key; do
+ hash="${key##*/}"
+ archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)"
+ dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout
+ mkdir -p "$dir"
+ cp "$archive" "$dir/"
+ echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt"
+ done <"$RUNNER_TEMP/new-keys.txt"
+
+ count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")"
+ echo "vcpkg scope $VCPKG_SCOPE: $count entries to import"
+ echo "count=$count" >>"$GITHUB_OUTPUT"
+ echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT"
+
+ # archive: false accepts a single file and nothing else, and a scope hands over one
+ # archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner.
+ # Zipping the directory keeps the fanout at the artifact root, which is where
+ # vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it.
+ - name: "Upload new vcpkg entries"
+ # An empty count means the step above did not run at all, on master and on forks
+ if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
+ uses: actions/upload-artifact@v7
with:
- key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
- path: ${{ env.CCACHE_DIR }}
+ if-no-files-found: error # a count above zero with nothing staged is a bug
+ name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
+ path: ${{ runner.temp }}/vcpkg-new
+ retention-days: 1
- name: "[macOS] Sign app bundle"
if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null
@@ -592,17 +1090,6 @@ jobs:
if-no-files-found: error
path: ${{ steps.build.outputs.path }}
- - name: "[Windows] Upload PDBs (Program Databases)"
- if: matrix.os == 'Windows' && github.ref_type != 'tag'
- uses: actions/upload-artifact@v7
- with:
- if-no-files-found: error
- name: ${{ steps.build.outputs.name }}-PDBs
- path: |
- build/cockatrice/Release/*.pdb
- build/oracle/Release/*.pdb
- build/servatrice/Release/*.pdb
-
- name: "Upload to release"
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
id: upload_release
@@ -614,6 +1101,33 @@ jobs:
tag_name: ${{ needs.configure.outputs.tag }}
run: gh release upload "$tag_name" "$asset_path#$asset_name"
+ # Ninja is single-config: binaries and their PDBs land directly in the
+ # target directories instead of a Release subdirectory.
+ #
+ # Archived for tag builds only, and ordered after the release upload so
+ # a missing PDB can never block a release from being published. On a tag
+ # build this artifact is the only durable copy of the symbols: the Sentry
+ # upload is continue-on-error, so without it a failed upload would lose
+ # the ability to symbolicate that release permanently. Other builds upload
+ # no symbols at all, so archiving them there would only cost storage.
+ #
+ # The executables travel with their PDBs: re-uploading the pair together is
+ # what lets sentry-cli associate the PE's code identifier with the PDB's
+ # debug identifier, and the PE is what carries the unwind information.
+ - name: "[Windows] Upload debug symbols (exe + pdb)"
+ if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
+ uses: actions/upload-artifact@v7
+ with:
+ if-no-files-found: error
+ name: ${{ steps.build.outputs.name }}-Symbols
+ path: |
+ build/cockatrice/*.exe
+ build/cockatrice/*.pdb
+ build/oracle/*.exe
+ build/oracle/*.pdb
+ build/servatrice/*.exe
+ build/servatrice/*.pdb
+
- name: "Attest binary provenance"
if: steps.upload_release.outcome == 'success'
id: attestation
@@ -629,3 +1143,143 @@ jobs:
BUILD_PATH: ${{ steps.build.outputs.path }}
GH_TOKEN: ${{ github.token }}
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
+
+ build-vcpkg-nightly:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: macOS
+ target: 13 # EOL 2025-09-15
+ runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
+ # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
+
+ cmake_generator: Ninja
+ make_package: 1
+ override_target: 13
+ package_suffix: "-macOS13_Intel"
+ qt_version: 6.11.*
+ qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
+ soc: Intel
+ type: Release
+ vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
+ vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
+
+ name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
+ needs: configure
+ # The EOL target on the slowest runner is too slow to gate a change, so it runs in the
+ # nightly and on tags only, where the vcpkg cache import also has to accept schedule runs.
+ if: github.event_name == 'schedule' || github.ref_type == 'tag'
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 100
+ steps: *vcpkg-steps
+
+ prune-caches:
+ name: Prune caches
+ # Only master writes caches at all, so only master may clean them up too
+ if: github.ref == 'refs/heads/master'
+ runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
+ continue-on-error: true # freeing space must never fail a build
+
+ steps:
+ # The repository holds 10 GB of cache against entries that want more, and GitHub evicts
+ # the least recently used entry whenever that is exceeded. The vcpkg entries are the
+ # reason that never happens: the restore above touches every entry under the prefix on
+ # every run, so a dead ABI generation keeps its access date fresh forever and age based
+ # eviction can never reach it. Deleting by age alone cannot fix that, only knowing which
+ # scope an entry belongs to can. The same is true of a compiler cache of a leg that no
+ # longer keeps one: its restore would keep touching it, so the live key list below is
+ # what retires it.
+ #
+ # docker-release's image builds keep their layer cache in the same quota: one blob per
+ # layer, keyed buildkit-blob-1-, plus one index--1- entry per image
+ # scope that lists them. They are written with mode=min, so only the final image stage
+ # is kept and the set stays small. A layer a build actually needs is downloaded, which
+ # refreshes its access date, so an old date means the builds have stopped using it and
+ # age is the right test. Dropping a blob an index still lists costs the next image build
+ # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
+ # entries forever.
+ #
+ # codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit,
+ # keyed codeql-trap----, about 1.07 GB each. Each run
+ # restores the newest entry by prefix and saves its own, so every run that analyzes a new
+ # commit makes the previous entry dead, and the action never deletes them: four had
+ # accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for
+ # the age rule, because they are not the oldest entries and so not what least-recently-
+ # used eviction reaches: the buildkit layers are. Only the newest can be restored again,
+ # and a miss costs one re-extraction in a job that reports rather than gates, so keeping
+ # exactly that one is safe.
+ - name: "Delete unusable cache entries"
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly
+ # matrices above, or the entries of a scope that is still in use get deleted
+ # on the next run.
+ LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
+ # Must list the ccache key of every leg that keeps a compiler cache
+ # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted.
+ # The nightly legs and the pull-request legs that finish under the Windows leg
+ # keep none, so their leftover entries are reclaimed here instead of waiting out
+ # the age rule.
+ LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release'
+ run: |
+ gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
+ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
+
+ cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
+ live=" ${LIVE_VCPKG_SCOPES} "
+ live_ccache=" ${LIVE_CCACHE_KEYS} "
+ # The one trap cache a later run can still restore, by prefix. ISO-8601 sorts
+ # lexicographically, so this is a plain string compare. Entries scoped to another
+ # ref are deleted by the ref rule below whatever this says.
+ newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")"
+
+ deleted=0
+ while IFS=$'\t' read -r id ref key accessed; do
+ [[ -n $id ]] || continue
+ # Only ever touch the key shapes our workflows create (buildkit-* and index-*
+ # come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml),
+ # so that an entry of some other workflow can never be deleted by accident
+ case "$key" in
+ vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;;
+ *) continue ;;
+ esac
+
+ reason=""
+ if [[ $ref != refs/heads/master ]]; then
+ reason="scoped to $ref"
+ elif [[ $key == ccache-* ]]; then
+ # An exact token compare, because one key can be a prefix of another:
+ # ccache-Fedora45 is a prefix of ccache-Fedora45debug and
+ # ccache-Fedora45release, and only the latter two are live.
+ if [[ $live_ccache != *" $key "* ]]; then
+ reason="no pull-request leg keeps it"
+ fi
+ elif [[ $key == vcpkg/* ]]; then
+ # "/", cut the hash off the end to get the scope
+ scope="vcpkg/${key#vcpkg/}"
+ scope="${scope%/*}/"
+ if [[ $live != *" $scope "* ]]; then
+ reason="no matrix leg uses $scope"
+ fi
+ elif [[ $key == codeql-trap-* ]]; then
+ if [[ $key != "$newest_trap" ]]; then
+ reason="superseded by the newest trap cache"
+ fi
+ fi
+ if [[ -z $reason ]]; then
+ stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
+ if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then
+ reason="unused since $accessed"
+ fi
+ fi
+
+ if [[ -n $reason ]]; then
+ echo "::notice::deleting $key, $reason"
+ gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id"
+ deleted=$((deleted + 1))
+ fi
+ done <"$RUNNER_TEMP/caches.tsv"
+
+ echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries"
diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml
index 93ba79464..f42f26e6a 100644
--- a/.github/workflows/desktop-lint.yml
+++ b/.github/workflows/desktop-lint.yml
@@ -12,7 +12,7 @@ on:
- '!.ci/**'
- '!.github/**'
- '!.tx/**'
- - '!doc/**'
+ - '!docs/**'
- '.ci/lint_cpp.sh'
- '.github/workflows/desktop-lint.yml'
- '.clang-format'
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 937386940..1537d6d4d 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -84,12 +84,18 @@ jobs:
password: ${{ github.token }}
# Don't push for non-publishing triggers
+ #
+ # The layer cache shares the repository's 10 GB Actions cache quota with the compiler
+ # caches (see CCACHE_SIZE in desktop-build.yml). mode=min keeps only the final image
+ # stage: the build stage sits behind `COPY . .`, so its compile layer is invalidated by
+ # every source change and was never read back, yet it was the bulk of the ~2 GB this
+ # cache held. The final runtime stage is the part that still pays off.
- name: "Build image"
if: steps.login.outcome != 'success'
uses: docker/build-push-action@v7
with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
- cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }}
+ cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }}
context: .
platforms: ${{ matrix.platform }}
push: false
@@ -101,7 +107,7 @@ jobs:
uses: docker/build-push-action@v7
with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
- cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
+ cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }}
context: .
labels: |
org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }}
diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml
index 91ee14e3e..b2f85e267 100644
--- a/.github/workflows/documentation-build.yml
+++ b/.github/workflows/documentation-build.yml
@@ -6,7 +6,7 @@ permissions:
on:
pull_request:
paths:
- - 'doc/doxygen/**'
+ - 'docs/**'
- '.github/workflows/documentation-build.yml'
- 'Doxyfile'
release:
@@ -71,7 +71,7 @@ jobs:
destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/
external_repository: Cockatrice/cockatrice.github.io
publish_branch: master
- publish_dir: ./docs/html
+ publish_dir: ./docs-output/html
- name: Link to Documentation Page
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml
new file mode 100644
index 000000000..0a377a8fd
--- /dev/null
+++ b/.github/workflows/vcpkg-cache-import.yml
@@ -0,0 +1,123 @@
+name: Import vcpkg cache
+
+# After a runner image update changes every port's ABI hash, the first master run
+# rebuilds protobuf, openssl and abseil from source and saves them into the shared
+# cache, where every later run can restore them.
+#
+# Only master pushes and the nightly schedule run on master are imported here: the
+# collect step, keys.txt and the zip contents all come from the workflow run being
+# imported, so importing from an unmerged pull request would let anyone who can push
+# a branch — or a bot actor such as Dependabot, whose PRs are same-repo — promote
+# arbitrary binaries into the scope that master and tag builds restore and link into
+# signed releases. Forks build untrusted code and must never reach that scope either.
+# A scheduled run is the default branch at the commit a master push already built, so
+# it carries the same trust as a push.
+#
+# repository_dispatch is not used on purpose: it would also resolve to the default branch,
+# but it counts as a low-trust trigger and may only write there when it declares a
+# write-capable cache-mode, which puts it in the same position as workflow_run.
+
+permissions:
+ actions: write # needed to save entries into the default branch's cache scope
+ contents: read
+
+on:
+ workflow_run:
+ workflows: ["Build Desktop"]
+ types: [completed]
+
+# Every import writes the same scopes, so let them queue instead of racing each other
+concurrency:
+ group: vcpkg-cache-import
+ cancel-in-progress: false
+
+jobs:
+ list:
+ name: List new vcpkg entries
+ # Only a successful push to the default branch has archives worth importing.
+ # Only code that landed on master may populate the scope master runs restore.
+ # Bot actors are excluded on top: their runs must never promote binaries into
+ # the shared scope even if they somehow target the default branch.
+ if: >-
+ github.event.workflow_run.conclusion == 'success' &&
+ (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
+ github.event.workflow_run.head_branch == 'master' &&
+ github.event.workflow_run.head_repository.full_name == github.repository &&
+ !endsWith(github.event.workflow_run.actor.login, '[bot]') &&
+ !endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
+ runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
+ # workflow_run is a low-trust trigger, so cache access is read-only by default even
+ # though this run resolves to the default branch. Assert below that this took effect,
+ # because a save under read-only access only warns and would import nothing silently.
+ cache-mode: write
+ outputs:
+ entries: ${{ steps.scan.outputs.entries }}
+
+ steps:
+ - name: "Download new vcpkg entries"
+ uses: actions/download-artifact@v8
+ with:
+ pattern: vcpkg-new-*
+ path: incoming
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Assert write access"
+ shell: bash
+ run: |
+ if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
+ echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
+ exit 1
+ fi
+
+ - name: "List cache entries"
+ id: scan
+ shell: bash
+ working-directory: incoming
+ run: |
+ entries="$(find . -name keys.txt -type f -exec sh -c '
+ for manifest; do
+ artifact="$(basename "$(dirname "$manifest")")"
+ while read -r key path; do
+ printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \
+ "$artifact" "$key" "$path"
+ done <"$manifest"
+ done
+ ' _ {} +)"
+ [[ -n $entries ]] || exit 0
+ # Compact, because a multi line value cannot be written to GITHUB_OUTPUT
+ entries="$(jq -cs '.' <<<"$entries")"
+ echo "entries=$entries" >>"$GITHUB_OUTPUT"
+ echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
+
+ save:
+ name: "Save ${{ matrix.entry.key }}"
+ needs: list
+ if: needs.list.outputs.entries
+ runs-on: ubuntu-slim
+ cache-mode: write
+ strategy:
+ # One job per archive, they run in parallel and each only uploads a single file
+ fail-fast: false
+ max-parallel: 20
+ matrix:
+ entry: ${{ fromJSON(needs.list.outputs.entries) }}
+
+ steps:
+ - name: "Download new vcpkg entries"
+ uses: actions/download-artifact@v8
+ with:
+ pattern: ${{ matrix.entry.artifact }}
+ path: incoming
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Save vcpkg entry"
+ uses: actions/cache/save@v6
+ with:
+ # The payload is a Windows or macOS archive while the upload runs on Linux, and
+ # without this the entry would be stamped as a Linux cache that neither of them
+ # is allowed to restore.
+ enableCrossOsArchive: true
+ key: ${{ matrix.entry.key }}
+ path: incoming/${{ matrix.entry.path }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 54939bdcf..ef88b1f58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,4 @@ compile_commands.json
.cache
.gdb_history
cockatrice/resources/config/qtlogging.ini
-docs/
+docs-output/
diff --git a/.gitmodules b/.gitmodules
index 732fec7ca..e6f115eb5 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -2,5 +2,8 @@
path = vcpkg
url = https://github.com/microsoft/vcpkg.git
[submodule "doxygen-awesome-css"]
- path = doc/doxygen/theme
+ path = docs/doxygen/theme
url = https://github.com/jothepro/doxygen-awesome-css.git
+[submodule "external/qttesting"]
+ path = external/qttesting
+ url = https://github.com/Kitware/QtTesting.git
diff --git a/.tx/github_integration_config.yml b/.tx/github_integration_config.yml
new file mode 100644
index 000000000..2586b4800
--- /dev/null
+++ b/.tx/github_integration_config.yml
@@ -0,0 +1,16 @@
+# Integration Docs: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_de29ef5cec
+# Config Parameter: https://help.transifex.com/en/articles/6265125-github-installation-and-configuration#h_a734c3e219
+# Supported Files: https://help.transifex.com/en/collections/3519116-file-formats
+
+filters:
+ - filter_type: file
+ file_format: QT
+ source_language: en
+ source_file: cockatrice/cockatrice_en@source.ts
+ translation_files_expression: cockatrice/translations/cockatrice_.ts
+
+ - filter_type: file
+ file_format: QT
+ source_language: en
+ source_file: oracle/oracle_en@source.ts
+ translation_files_expression: oracle/translations/oracle_.ts
diff --git a/CMakeLists.txt b/CMakeLists.txt
index bee47972c..b0f6a8db0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,11 +1,11 @@
# Cockatrice's main CMakeLists.txt
#
-# This is basically a wrapper to enable/disable the compilation
-# of the different projects: servatrice, cockatrice, test
+# This is basically a wrapper to enable/disable the compilation of the different projects:
+# Cockatrice, Oracle, Servatrice, Test
# This file sets all the variables shared between the projects
# like the installation path, compilation flags etc..
-# 3.16 required for Qt6 and target_precompile_headers()
+# CMake 3.16 is required for Qt6 and target_precompile_headers()
cmake_minimum_required(VERSION 3.16)
# Use compiler cache (ccache)
@@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON)
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
# Compile Servatrice
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
+# Compile GUI tests (QtTesting widget-level playback)
+option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF)
# Compile tests
option(TEST "Build tests" OFF)
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
@@ -29,17 +31,20 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
-# Default to "Release" build type
-# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
-if(DEFINED CMAKE_BUILD_TYPE)
- set(CMAKE_BUILD_TYPE
- ${CMAKE_BUILD_TYPE}
- CACHE STRING "Type of build"
+# The GUI test harness lives in a submodule; a checkout without it must still configure.
+if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt")
+ message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n"
+ "Run 'git submodule update --init external/qttesting' to enable them."
)
-else()
+ set(WITH_GUI_TEST OFF)
+endif()
+
+# Default to "Release" build type
+# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call
+if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE
Release
- CACHE STRING "Type of build"
+ CACHE STRING "Build type"
)
endif()
@@ -55,6 +60,10 @@ if(USE_CCACHE AND NOT WIN32)
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
# to cache any TU that consumes a precompiled header, so every PCH-backed
# target recompiles from scratch on each build.
+ # The include_file_mtime/include_file_ctime entries that CI also sets are
+ # deliberately not set here: --set-config writes the user's global
+ # ccache.conf, and those two would turn off the modified-during-compile
+ # check for every project on the machine, not just this one.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif()
@@ -71,6 +80,8 @@ if(WIN32 OR USE_VCPKG)
)
# Qt path set by user or env var
if(QTDIR
+ OR QTDIR32
+ OR QTDIR64
OR DEFINED ENV{QTDIR}
OR DEFINED ENV{QTDIR32}
OR DEFINED ENV{QTDIR64}
@@ -90,22 +101,23 @@ endif()
# A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake
-project("Cockatrice" VERSION 3.1.2)
+project("Cockatrice" VERSION 3.2.0)
-# Set release name if not provided via env/cmake var
+# Set release name if not provided via ENV/CMake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
set(GIT_TAG_RELEASENAME "Reality Check")
endif()
-# Use c++20 for all targets
+# Use C++20 for all targets
set(CMAKE_CXX_STANDARD
20
CACHE STRING "C++ ISO Standard"
)
-set(CMAKE_CXX_STANDARD_REQUIRED True)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
# Set conventional loops
-set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true)
+set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
# Search path for cmake modules
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
@@ -118,31 +130,31 @@ include(createversionfile)
# Define a proper install path
if(UNIX)
- if(APPLE)
- # macOS
- # Due to the special bundle structure ignore
- # the prefix eventually set by the user.
+ if(APPLE) # macOS
+ # Due to the special bundle structure ignore the prefix eventually set by the user
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
- # Force ccache usage if available
- get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
- if(RULE_LAUNCH_COMPILE)
- message(STATUS "Force enabling CCache usage under macOS")
- # Set up wrapper scripts
- configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
- configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
- execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
+ # Force ccache usage if requested and available
+ if(USE_CCACHE)
+ get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
+ if(RULE_LAUNCH_COMPILE)
+ message(STATUS "Force enabling ccache usage under macOS")
- # Set Xcode project attributes to route compilation through our scripts
- set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c")
- set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx")
- set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
- set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
+ # Set up wrapper scripts
+ configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
+ configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
+ execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
+
+ # Set Xcode project attributes to route compilation through our scripts
+ set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c")
+ set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx")
+ set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
+ set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
+ endif()
endif()
- else()
- # Linux / BSD
+ else() # Linux / BSD
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
- #fix package build
+ # Fix package build
if(PREFIX)
set(CMAKE_INSTALL_PREFIX ${PREFIX})
else()
@@ -150,47 +162,67 @@ if(UNIX)
endif()
endif()
endif()
-elseif(WIN32)
+elseif(WIN32) # Windows (including 64-bit)
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
endif()
-# Define proper compilation flags
-if(MSVC)
- # Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols
- set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi")
- # Visual Studio: Maximum Optimization, Multi-threaded DLL
- set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD")
- # Visual Studio: No Optimization, Multi-threaded Debug DLL
+# Define compiler flags
+if(MSVC) # Microsoft Visual C++ compiler
+ # /EHsc Enable standard C++ exception handling
+ # /MP Enable parallel compilation
+ # /permissive- Enable more standards-conforming behavior
+ # /utf-8 Set source file encoding and execution character set to UTF-8
+ # /W4 Enable warning level 4
+ # /Zc:__cplusplus Enable correct __cplusplus reporting
+ # /Zi Generate debugging information (Program Database, PDB)
+ set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Zi")
+ # /O2 Balanced optimization
+ # /MD Link against the multi-threaded DLL runtime library (Release CRT)
+ set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD")
+ # /Od Disable optimization
+ # /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT)
set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd")
- # Generate PDB, even when in release (So developers can better analyze crash logs)
+ # Generate PDBs, even in release builds, so developers can better analyze crash logs
+ # /DEBUG Enable debug symbols and PDB generation
+ # /OPT:REF Remove unreferenced functions/data
+ # /OPT:ICF Fold identical COMDAT functions/data
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
-elseif(CMAKE_COMPILER_IS_GNUCXX)
- # linux/gcc, bsd/gcc, windows/mingw
+elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
include(CheckCXXCompilerFlag)
- set(CMAKE_CXX_FLAGS_RELEASE "-s -O2")
+ # -O2 Balanced optimization
+ # -s Remove symbols from the executable
+ set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s")
+
+ # -ggdb Produce GDB debugging symbols
+ # -O0 Disable optimization
+ # -Wall Enable a broad set of useful warnings
+ # -Wextra Enable a set of extra warnings
+ # -Werror Treat warnings as compilation errors
if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
else()
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
endif()
- if(APPLE)
- set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
- endif()
-
+ # -Wcast-align Catch unsafe pointer casts
+ # -Wmissing-declarations Catch defined functions without a prior declaration
+ # -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings
+ # -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning
+ # -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning
+ # -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning
+ # -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this
set(ADDITIONAL_DEBUG_FLAGS
-Wcast-align
-Wmissing-declarations
- -Wno-long-long
-Wno-error=extra
-Wno-error=delete-non-virtual-dtor
-Wno-error=sign-compare
-Wno-error=missing-declarations
- -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this
+ -Wno-error=sfinae-incomplete
)
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
@@ -202,9 +234,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
-else()
- # other: osx/llvm, bsd/llvm
+elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers
+ # -O2 Balanced optimization
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
+
+ # -g Include debug information
+ # -O0 Disable optimization
+ # -Wall Enable a broad set of useful warnings
+ # -Wextra Enable a set of extra warnings
+ # -Werror Treat warnings as compilation errors
+ # -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks)
if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
else()
@@ -213,6 +252,8 @@ else()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
+else() # Unknown compiler
+ message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
endif()
# Always included, because they define cockatrice_disable_sanitizers_for() and
@@ -220,10 +261,32 @@ endif()
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
include(Sanitizers)
include(Coverage)
+include(CrashReporting)
-# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
+# Debug information is what makes a cached object large, and a build configuration needs more
+# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to
+# its limit and evicts the entries the next build is about to ask for, so it serves a few
+# percent of calls while holding most of a build. CI sets a lower level so one whole
+# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer
+# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as
+# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot
+# raise the level again.
+set(COCKATRICE_DEBUG_INFO_LEVEL
+ ""
+ CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)"
+)
+if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "")
+ if(MSVC)
+ message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC")
+ else()
+ string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
+ string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
+ endif()
+endif()
+
+# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
- add_definitions("-DSFMT_MEXP=19937")
+ add_compile_definitions(SFMT_MEXP=19937)
endif()
find_package(Threads REQUIRED)
@@ -235,13 +298,13 @@ else()
set(_lib_suffix 32)
endif()
-if(DEFINED QTDIR${_lib_suffix})
+if(NOT "${QTDIR${_lib_suffix}}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}")
-elseif(DEFINED QTDIR)
+elseif(NOT "${QTDIR}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "${QTDIR}")
-elseif(DEFINED ENV{QTDIR${_lib_suffix}})
+elseif(NOT "$ENV{QTDIR${_lib_suffix}}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}")
-elseif(DEFINED ENV{QTDIR})
+elseif(NOT "$ENV{QTDIR}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}")
endif()
@@ -249,7 +312,22 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}")
include(FindQtRuntime)
+# Reject APIs that were deprecated before the oldest Qt release we support
+# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found
+# version understands while still compiling everywhere.
+if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5)
+ add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400)
+else()
+ add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400)
+endif()
+
+# Set once here for every target: these variables are inherited by the
+# subdirectories, so the per-directory copies were redundant. Only the
+# variables are set; qt_standard_project_setup would also change the runtime
+# output directories and the install RPATH.
set(CMAKE_AUTOMOC TRUE)
+set(CMAKE_AUTOUIC TRUE)
+set(CMAKE_AUTORCC TRUE)
# Find other needed libraries
find_package(Protobuf CONFIG)
@@ -261,15 +339,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC
message(FATAL_ERROR "No protoc command found!")
endif()
-#Find OpenSSL
-if(WIN32)
+# Find OpenSSL
+if(WIN32) # Windows (including 64-bit)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
endif()
endif()
-#Find VCredist
+# Find Visual C++ Redistributable
if(MSVC)
find_package(VCredistRuntime)
endif()
@@ -327,7 +405,7 @@ if(UNIX)
endif()
elseif(WIN32)
set(CPACK_GENERATOR NSIS ${CPACK_GENERATOR})
- if("${CMAKE_GENERATOR_PLATFORM}" MATCHES "(x64)")
+ if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(TRICE_IS_64_BIT 1)
else()
set(TRICE_IS_64_BIT 0)
@@ -336,6 +414,15 @@ elseif(WIN32)
# Configure file with custom definitions for NSIS.
configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh")
+ # Installer-log sender for the AbortInstall Sentry hook (values parsed out of COCKATRICE_SENTRY_DSN in
+ # cmake/CrashReporting.cmake). Generated only when a DSN was parsed: cmake/NSIS.template.in embeds it under
+ # the same CPACK_SENTRY_ENABLED check, so a build without reporting never references or carries the helper.
+ if(CPACK_SENTRY_ENABLED)
+ configure_file(
+ "${COCKATRICE_CMAKE_PATH}/SendInstallerLog.ps1.in" "${PROJECT_BINARY_DIR}/SendInstallerLog.ps1" @ONLY
+ )
+ endif()
+
# Every runtime DLL a Windows install has to contain, as paths relative to the
# install root. .ci/compile.sh reads this and fails the build when the packaged
# installer is missing one of them, or carries a runtime it does not know about.
@@ -410,6 +497,9 @@ if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models)
add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters)
endif()
+if(WITH_CLIENT)
+ add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game)
+endif()
if(WITH_SERVER)
add_subdirectory(servatrice)
@@ -421,11 +511,46 @@ if(WITH_CLIENT)
set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
+if(WIN32 AND WITH_CLIENT)
+ # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed
+ # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install
+ # rules and is not part of the client's staged tree.
+ add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe)
+endif()
+
if(WITH_ORACLE)
add_subdirectory(oracle)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
+if(WITH_GUI_TEST)
+ set(QtTesting_QT_VERSION "6")
+ # QtTesting registers its own unit tests when BUILD_TESTING is on. They target the
+ # Qt version the harness was written for and fail on Qt 6.11 (toolbutton
+ # long-press recording), and they are not Cockatrice's tests, so suppress them
+ # for the duration of the subdirectory instead of painting ctest red.
+ if(DEFINED BUILD_TESTING)
+ set(_cockatrice_saved_build_testing ${BUILD_TESTING})
+ endif()
+ set(BUILD_TESTING
+ OFF
+ CACHE BOOL "Build the testing tree." FORCE
+ )
+ add_subdirectory(external/qttesting)
+ if(DEFINED _cockatrice_saved_build_testing)
+ set(BUILD_TESTING
+ ${_cockatrice_saved_build_testing}
+ CACHE BOOL "Build the testing tree." FORCE
+ )
+ else()
+ unset(BUILD_TESTING CACHE)
+ endif()
+ # cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021.
+ if(NOT MSVC)
+ target_compile_options(qttesting PRIVATE -Wno-error)
+ endif()
+endif()
+
if(TEST)
include(CTest)
add_subdirectory(tests)
@@ -433,5 +558,12 @@ endif()
if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt6.3+ requires project finalization to support translations
- qt6_finalize_project()
+ qt_finalize_project()
endif()
+
+# Print compiler identification at configuration time
+message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
+message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}")
+message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}")
+message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
+message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}")
diff --git a/Doxyfile b/Doxyfile
index c12ff4a8a..0d508ca38 100644
--- a/Doxyfile
+++ b/Doxyfile
@@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png
# entered, it will be relative to the location where Doxygen was started. If
# left blank the current directory will be used.
-OUTPUT_DIRECTORY = docs
+OUTPUT_DIRECTORY = docs-output
# If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096
# sub-directories (in 2 levels) under the output directory of each output format
@@ -874,7 +874,7 @@ FILE_VERSION_FILTER =
# DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE
# tag is left empty.
-LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml
+LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml
# The CITE_BIB_FILES tag can be used to specify one or more bib files containing
# the reference definitions. This must be a list of .bib files. The .bib
@@ -1091,8 +1091,8 @@ RECURSIVE = YES
EXCLUDE = build/ \
cmake/ \
cmake-build-debug/ \
- doc/doxygen/theme/docs/ \
- doc/doxygen/theme/include/ \
+ docs/doxygen/theme/docs/ \
+ docs/doxygen/theme/include/ \
vcpkg/
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
@@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO
# that contain images that are to be included in the documentation (see the
# \image command).
-IMAGE_PATH = doc/doxygen/images
+IMAGE_PATH = docs/doxygen/images
# The INPUT_FILTER tag can be used to specify a program that Doxygen should
# invoke to filter for each input file. Doxygen will invoke the filter program
@@ -1183,7 +1183,7 @@ INPUT_FILTER =
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
# properly processed by Doxygen.
-FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py"
+FILTER_PATTERNS = "*.proto=python docs/doxygen/filters/proto2cpp.py"
# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using
# INPUT_FILTER) will also be used to filter the input files that are used for
@@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html
# of the possible markers and block names see the documentation.
# This tag requires that the tag GENERATE_HTML is set to YES.
-HTML_HEADER = doc/doxygen/html/header.html
+HTML_HEADER = docs/doxygen/html/header.html
# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each
# generated HTML page. If the tag is left blank Doxygen will generate a standard
@@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html
# that Doxygen normally uses.
# This tag requires that the tag GENERATE_HTML is set to YES.
-HTML_FOOTER = doc/doxygen/html/footer.html
+HTML_FOOTER = docs/doxygen/html/footer.html
# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style
# sheet that is used by each HTML page. It can be used to fine-tune the look of
@@ -1454,9 +1454,9 @@ HTML_STYLESHEET =
# documentation.
# This tag requires that the tag GENERATE_HTML is set to YES.
-HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
- doc/doxygen/css/hide_nav_sync.css \
- doc/doxygen/css/cockatrice_docs_style.css
+HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \
+ docs/doxygen/css/hide_nav_sync.css \
+ docs/doxygen/css/cockatrice_docs_style.css
# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or
# other source files which should be copied to the HTML output directory. Note
@@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
# files will be copied as-is; there are no commands or markers available.
# This tag requires that the tag GENERATE_HTML is set to YES.
-HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js
+HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js
# The HTML_COLORSTYLE tag can be used to specify if the generated HTML output
# should be rendered with a dark or light theme.
diff --git a/README.md b/README.md
index f2a170868..03f02e540 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,10 @@
---
-To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
-To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
-To help with development ⇢ learn [how to contribute](#contribute-)
+To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
+To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
+To learn about the app and its code ⇢ [read our code documentation](https://cockatrice.github.io/docs/)
+To help with development ⇢ learn [how to contribute](#contribute)
@@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p
### Code
-To contribute code to the project, please review our [guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on:
- [](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
@@ -95,16 +96,17 @@ We'll happily advice on how best to implement a feature, or we can show you wher
You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code).
-### Documentation [](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush)
+### Documentation [](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch)
There are various places where useful information for different needs are maintained:
- [Official Code Documentation](https://cockatrice.github.io/docs/)
- [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported`
- [Official Webpage](https://cockatrice.github.io/)
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
-- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md)
+- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md)
-Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide.
+Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation.
+We encourage everybody to improve the documentation by suggesting edits based on this guide.
### Translation [](https://explore.transifex.com/cockatrice/cockatrice/)
@@ -161,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`:
| `-DTEST=1` | Enable regression tests **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
> [!TIP]
-> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`:
+> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`:
> ```bash
> doxygen Doxyfile
> ```
diff --git a/Testing/README.md b/Testing/README.md
new file mode 100644
index 000000000..cb990ec29
--- /dev/null
+++ b/Testing/README.md
@@ -0,0 +1,66 @@
+# GUI tests
+
+Widget-level playback tests for the Cockatrice client, driven by
+[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule).
+Each `*_test.xml` file is a list of events that QtTesting replays against the running
+application; a test passes when every event is handled without error.
+
+## Running
+
+```bash
+git submodule update --init external/qttesting # once
+cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON
+cmake --build build/debug -j 24
+
+./Testing/run_test.sh --all # headless, offscreen
+./Testing/run_test.sh Testing/smoke_test.xml # one file
+./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window
+```
+
+`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a
+warning and no harness. `COCKATRICE_BIN` points the runner at another build, and
+`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a
+modal dialog can otherwise leave the process alive. The client also hard-exits a few
+seconds after playback returns, whatever the result.
+
+The tests are **not** part of `ctest`: they need a card database and a developer's
+settings, so they stay a local loop for now.
+
+## Caveats
+
+- **Not hermetic.** The client reads the local settings and card database, so a test can
+ pass on one machine and fail on another. Avoid asserting things that restored
+ workspace state or user preferences decide (for example the number of open tabs).
+- **English labels only.** Menu entries are addressed by their (translated) text, so the
+ tests expect the source language. Widgets are addressed by objectName paths, which are
+ stable across translations.
+- Actions that open a dialog are followed by a `pause` event: playback continues in the
+ dialog's own event loop, and the pause gives the UI time to build itself.
+- Widget paths include Qt-internal names where a QTabWidget is involved
+ (`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed
+ (`tabbedDeckView_0`, `tabbedDeckView_1`).
+
+## Writing a test
+
+A test is a `` list. Widgets are named by their path from the main window, e.g.
+`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands:
+
+| Command | Arguments | Effect |
+|---------|-----------|--------|
+| `activate` | action or menu name | triggers a QAction (menus are opened first) |
+| `key` | Qt key code | presses that key on the focused widget |
+| `pause` | milliseconds | waits, letting the UI settle |
+| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) |
+| `flipCard` | `card,zone` | right-click → *Turn Over* |
+| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) |
+| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone |
+| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards |
+| `createToken` | `name,color,pt` | creates a token as the active local player |
+| `selectItem` | `item label` | selects the tree/list entry with that label |
+| `typeText` | `text` | types text one key event at a time |
+| `expectProperty` | `property,value` | fails playback unless the widget property matches |
+
+The card, item and property commands are `pqCockatriceEventPlayer`
+(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name
+and zone through the `QGraphicsScene` and send real mouse events at the card's current
+viewport position, so a test does not depend on the window size or screen resolution.
diff --git a/Testing/card_actions_test.xml b/Testing/card_actions_test.xml
new file mode 100644
index 000000000..8d6de4c2b
--- /dev/null
+++ b/Testing/card_actions_test.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml
new file mode 100644
index 000000000..ab74b1c63
--- /dev/null
+++ b/Testing/deck_editor_open_test.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml
new file mode 100644
index 000000000..6641ac3b2
--- /dev/null
+++ b/Testing/deck_editor_search_test.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/dialog_lifecycle_test.xml b/Testing/dialog_lifecycle_test.xml
new file mode 100644
index 000000000..00510b802
--- /dev/null
+++ b/Testing/dialog_lifecycle_test.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/game_mechanics_test.xml b/Testing/game_mechanics_test.xml
new file mode 100644
index 000000000..efef78707
--- /dev/null
+++ b/Testing/game_mechanics_test.xml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml
new file mode 100644
index 000000000..33191d1ac
--- /dev/null
+++ b/Testing/integration_test.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/local_game_lifecycle_test.xml b/Testing/local_game_lifecycle_test.xml
new file mode 100644
index 000000000..bdc964921
--- /dev/null
+++ b/Testing/local_game_lifecycle_test.xml
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/menu_open_close_test.xml b/Testing/menu_open_close_test.xml
new file mode 100644
index 000000000..d894b47b5
--- /dev/null
+++ b/Testing/menu_open_close_test.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/nonmodal_manage_sets_test.xml b/Testing/nonmodal_manage_sets_test.xml
new file mode 100644
index 000000000..5fe2bfc0c
--- /dev/null
+++ b/Testing/nonmodal_manage_sets_test.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Testing/nonmodal_tip_test.xml b/Testing/nonmodal_tip_test.xml
new file mode 100644
index 000000000..0f4ffc825
--- /dev/null
+++ b/Testing/nonmodal_tip_test.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Testing/nonmodal_view_log_test.xml b/Testing/nonmodal_view_log_test.xml
new file mode 100644
index 000000000..3369f1fd5
--- /dev/null
+++ b/Testing/nonmodal_view_log_test.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Testing/run_test.sh b/Testing/run_test.sh
new file mode 100755
index 000000000..8ff3fe028
--- /dev/null
+++ b/Testing/run_test.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+# Run QtTesting XML test files against the Cockatrice build.
+#
+# Usage:
+# ./Testing/run_test.sh [--visual] [--all | ...]
+#
+# Options:
+# --visual Run with native display (shows the GUI window).
+# Default is headless (QT_QPA_PLATFORM=offscreen).
+# --all Run every *.xml file in Testing/.
+#
+# Environment:
+# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice,
+# or the .app bundle binary on macOS).
+# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180).
+# Uses timeout, or gtimeout from coreutils on macOS.
+#
+# Requires the debug build to exist at build/debug/cockatrice/cockatrice.
+
+set -euo pipefail
+shopt -s nullglob
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+if [ -n "${COCKATRICE_BIN:-}" ]; then
+ COCKATRICE="$COCKATRICE_BIN"
+elif [ "$(uname -s)" = "Darwin" ]; then
+ # A macOS build produces an app bundle, not a bare executable.
+ COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice"
+else
+ COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice"
+fi
+TEST_DIR="$REPO_ROOT/Testing"
+TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
+
+# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the
+# runner still works, but a hung test is not killed.
+TIMEOUT=()
+if command -v timeout >/dev/null 2>&1; then
+ TIMEOUT=(timeout --foreground "$TEST_TIMEOUT")
+elif command -v gtimeout >/dev/null 2>&1; then
+ TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT")
+else
+ echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2
+fi
+
+if [ ! -f "$COCKATRICE" ]; then
+ echo "Error: $COCKATRICE not found. Build first:" >&2
+ echo " cmake --build build/debug" >&2
+ exit 1
+fi
+
+VISUAL=0
+if [ "${1:-}" = "--visual" ]; then
+ VISUAL=1
+ shift
+fi
+
+if [ "${1:-}" = "--all" ]; then
+ shift
+ TEST_FILES=("$TEST_DIR"/*.xml)
+ if [ ${#TEST_FILES[@]} -eq 0 ]; then
+ echo "No *.xml files found in $TEST_DIR" >&2
+ exit 1
+ fi
+elif [ $# -ge 1 ]; then
+ TEST_FILES=("$@")
+else
+ echo "Usage: $0 [--visual] [--all | ...]" >&2
+ echo " Run a single test: $0 Testing/smoke_test.xml" >&2
+ echo " Run all tests: $0 --all" >&2
+ echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2
+ echo " Run all visually: $0 --visual --all" >&2
+ exit 1
+fi
+
+PASS=0
+FAIL=0
+FAILED_TESTS=()
+
+run_one() {
+ local file="$1"
+
+ local -a cmd=()
+ if [ "${#TIMEOUT[@]}" -gt 0 ]; then
+ cmd=("${TIMEOUT[@]}")
+ fi
+ cmd+=("$COCKATRICE" --no-connect --test "$file")
+
+ if [ "$VISUAL" -eq 1 ]; then
+ "${cmd[@]}"
+ else
+ env QT_QPA_PLATFORM=offscreen "${cmd[@]}"
+ fi
+}
+
+for f in "${TEST_FILES[@]}"; do
+ name="$(basename "$f")"
+ printf "%-40s " "$name"
+ if run_one "$f"; then
+ echo "PASS"
+ PASS=$((PASS + 1))
+ else
+ echo "FAIL"
+ FAIL=$((FAIL + 1))
+ FAILED_TESTS+=("$name")
+ fi
+done
+
+TOTAL=$((PASS + FAIL))
+echo ""
+echo "Results: $PASS/$TOTAL passed, $FAIL failed"
+if [ "$FAIL" -gt 0 ]; then
+ echo "Failed:"
+ for t in "${FAILED_TESTS[@]}"; do
+ echo " $t"
+ done
+ exit 1
+fi
diff --git a/Testing/settings_dialog_test.xml b/Testing/settings_dialog_test.xml
new file mode 100644
index 000000000..2dd9dda0a
--- /dev/null
+++ b/Testing/settings_dialog_test.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/Testing/settings_navigation_test.xml b/Testing/settings_navigation_test.xml
new file mode 100644
index 000000000..c112966e0
--- /dev/null
+++ b/Testing/settings_navigation_test.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/settings_search_test.xml b/Testing/settings_search_test.xml
new file mode 100644
index 000000000..ba60251f0
--- /dev/null
+++ b/Testing/settings_search_test.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Testing/smoke_test.xml b/Testing/smoke_test.xml
new file mode 100644
index 000000000..a31edbdd4
--- /dev/null
+++ b/Testing/smoke_test.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake
new file mode 100644
index 000000000..6f9cf4849
--- /dev/null
+++ b/cmake/CrashReporting.cmake
@@ -0,0 +1,212 @@
+# Crash reporting via sentry-native (Sentry protocol, MIT).
+# Included once from the root CMakeLists.txt, right after the sanitizer/coverage helpers.
+#
+# Inputs:
+# WITH_CRASH_REPORTING -- build the client with the sentry-native SDK (default OFF, our release
+# CI passes ON; packagers and from-source builds stay SDK-free until this
+# ships something real, so offline package builds never fetch the SDK)
+# COCKATRICE_SENTRY_DSN -- Sentry DSN baked into the build (default empty = SDK init skipped at runtime)
+# COCKATRICE_SENTRY_TAG -- sentry-native release tag to vendor via FetchContent
+# ENABLE_SANITIZERS -- forces the sentry backend to "none" (crashpad vs ASan conflict)
+# Defines: cockatrice_target_enable_crash_reporting()
+
+option(WITH_CRASH_REPORTING "Build with crash reporting via sentry-native" OFF)
+set(COCKATRICE_SENTRY_DSN
+ ""
+ CACHE STRING "Sentry DSN baked into the client; empty disables crash-report upload at runtime"
+)
+# Pinned stable release; tag verified at https://github.com/getsentry/sentry-native/tags.
+set(COCKATRICE_SENTRY_TAG
+ "0.17.3"
+ CACHE STRING "sentry-native release tag to vendor via FetchContent"
+)
+
+# Wire the SDK (or the no-op fallback) into a client target. Always callable: with the
+# option OFF the compile definitions below make crash_reporter.cpp compile to no-ops.
+function(cockatrice_target_enable_crash_reporting _target)
+ if(WITH_CRASH_REPORTING AND TARGET sentry::sentry)
+ target_link_libraries(${_target} PRIVATE sentry::sentry)
+ target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=1)
+ else()
+ target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=0)
+ endif()
+ # Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime.
+ target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"")
+ # Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this
+ # file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries
+ # a build date, so every build of the same version would land in a different Sentry release.
+ # PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the
+ # package version from, so client and installer events group under one release.
+ target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"")
+endfunction()
+
+if(WITH_CRASH_REPORTING AND WITH_CLIENT)
+ include(FetchContent)
+
+ if(ENABLE_SANITIZERS)
+ # The crash handler cannot run under ASan/UBSan, so there is no backend to capture with.
+ set(_sentry_backend "none")
+ else()
+ # Breakpad on every OS (increment 3 probe, 2026-10-14): the crashpad sources
+ # do vendor friction-free (FetchContent inits all submodules recursively by
+ # default, verified populated in a local build tree), but the crashpad backend
+ # would need its out-of-process crashpad_handler binary installed next to the
+ # exe, into the macOS bundle, and through NSIS, plus handler-path wiring and
+ # notarization care. Breakpad is in-process, needs no extra binary and no
+ # packaging on any OS sentry-native builds, so simplicity wins.
+ set(_sentry_backend "breakpad")
+ endif()
+ # FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind.
+ set(SENTRY_BACKEND
+ "${_sentry_backend}"
+ CACHE STRING "Crash handler backend for sentry-native" FORCE
+ )
+ unset(_sentry_backend)
+
+ if(NOT MSVC)
+ # Symbol upload needs debug info in Release binaries: the top-level flags build GCC
+ # Release as -s -O2 (stripped, no DWARF) and Clang/Apple Release as plain -O2 (no DWARF),
+ # both of which upload nothing useful. Keep -g and drop the strip flag while the SDK is
+ # on; the OFF default leaves local, distro and Debug builds untouched. MSVC already
+ # links /DEBUG with PDBs in release (see top-level CMakeLists.txt), so it needs nothing.
+ string(REGEX REPLACE "(^| )-s( |$)" "\\1\\2" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}")
+ if(NOT CMAKE_CXX_FLAGS_RELEASE MATCHES "(^| )-g( |$)")
+ set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g")
+ endif()
+ endif()
+
+ # No Qt integration: it runs find_package(Qt6) inside the vendored build, which
+ # cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its
+ # breadcrumbs could carry unredacted log lines into native crash events that bypass
+ # before_send. Our attached redacted log preview already carries the log context.
+ set(SENTRY_INTEGRATION_QT
+ OFF
+ CACHE BOOL "Qt log-message breadcrumbs for sentry-native (off: discovery + secret surface)" FORCE
+ )
+ # The transport stays at the SDK default (curl on Apple/Linux, winhttp on Windows).
+ set(SENTRY_BUILD_TESTS
+ OFF
+ CACHE BOOL "Build sentry-native tests" FORCE
+ )
+ set(SENTRY_BUILD_EXAMPLES
+ OFF
+ CACHE BOOL "Build sentry-native examples" FORCE
+ )
+
+ fetchcontent_declare(
+ sentry
+ GIT_REPOSITORY https://github.com/getsentry/sentry-native.git
+ GIT_TAG ${COCKATRICE_SENTRY_TAG}
+ GIT_SHALLOW TRUE
+ )
+ fetchcontent_makeavailable(sentry)
+
+ # Vendored sentry-native (and its breakpad/crashpad trees) warn under our -Wall -Wextra
+ # (e.g. breakpad's -Wmissing-field-initializers), and WARNING_AS_ERROR would fail the build
+ # on code we cannot fix. Those findings are not actionable for us, so opt the whole vendored
+ # tree out of warnings-as-errors while keeping the warnings themselves visible; our own
+ # targets keep -Werror. Interface libraries compile nothing, so they are skipped outright:
+ # an INTERFACE -Wno-error would propagate to our consumers and weaken their build.
+ function(_cockatrice_sentry_opt_out_of_werror _dir)
+ get_property(
+ _sentry_targets
+ DIRECTORY "${_dir}"
+ PROPERTY BUILDSYSTEM_TARGETS
+ )
+ foreach(_sentry_target IN LISTS _sentry_targets)
+ get_target_property(_sentry_type ${_sentry_target} TYPE)
+ if(_sentry_type STREQUAL "INTERFACE_LIBRARY")
+ continue()
+ elseif(_sentry_type MATCHES "LIBRARY|EXECUTABLE")
+ get_target_property(_sentry_imported ${_sentry_target} IMPORTED)
+ if(NOT _sentry_imported)
+ target_compile_options(${_sentry_target} PRIVATE -Wno-error)
+ endif()
+ endif()
+ endforeach()
+ get_property(
+ _sentry_subdirs
+ DIRECTORY "${_dir}"
+ PROPERTY SUBDIRECTORIES
+ )
+ foreach(_sentry_subdir IN LISTS _sentry_subdirs)
+ _cockatrice_sentry_opt_out_of_werror("${_sentry_subdir}")
+ endforeach()
+ endfunction()
+ if(NOT MSVC)
+ _cockatrice_sentry_opt_out_of_werror("${sentry_SOURCE_DIR}")
+ endif()
+
+ message(STATUS "Crash reporting: sentry-native ${COCKATRICE_SENTRY_TAG} (backend: ${SENTRY_BACKEND})")
+else()
+ message(STATUS "Crash reporting: SDK not vendored "
+ "(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})"
+ )
+endif()
+
+# Packaged release binaries must not ship the DWARF that the symbol upload needs: it is
+# 80-90% of an unstripped binary and nobody downloads a 500 MB installer to get it. The
+# build tree keeps -g (CI uploads symbols from there); the *packaged copy* is split at
+# install time by a CPack install script, which extracts the debug info next to the staging
+# dir and strips the binary. Only ELF (Linux/BSD) needs this: Windows links /DEBUG with
+# separate PDBs, and macOS keeps its dSYM extracted from the bundle.
+option(COCKATRICE_SPLIT_DEBUG_SYMBOLS "Strip DWARF out of packaged release binaries" ON)
+set(COCKATRICE_DEBUG_SYMBOL_DIR
+ "${CMAKE_BINARY_DIR}/debug-symbols"
+ CACHE PATH "Where the packaged binaries' split debug information is written (kept out of the package)"
+)
+if(WITH_CRASH_REPORTING
+ AND WITH_CLIENT
+ AND COCKATRICE_SPLIT_DEBUG_SYMBOLS
+ AND CMAKE_BUILD_TYPE STREQUAL "Release"
+ AND UNIX
+ AND NOT APPLE
+)
+ find_program(COCKATRICE_OBJCOPY NAMES objcopy)
+ find_program(COCKATRICE_STRIP_TOOL NAMES strip)
+ if(COCKATRICE_OBJCOPY AND COCKATRICE_STRIP_TOOL)
+ configure_file(
+ "${CMAKE_CURRENT_LIST_DIR}/SplitDebugSymbols.cmake.in" "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake" @ONLY
+ )
+ # PRE_BUILD runs after CPack stages the install tree and before the package is produced.
+ # CPACK_INSTALL_SCRIPTS would run too early (staging is still empty); the script walks
+ # CPACK_TEMPORARY_DIRECTORY, never CMAKE_INSTALL_PREFIX, and refuses to run otherwise.
+ list(APPEND CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake")
+ message(STATUS "Crash reporting: packaged binaries stripped, debug info split to ${COCKATRICE_DEBUG_SYMBOL_DIR}")
+ else()
+ message(WARNING "COCKATRICE_SPLIT_DEBUG_SYMBOLS is ON but objcopy/strip were not found; "
+ "packaged release binaries will keep their debug information"
+ )
+ endif()
+endif()
+
+# Installer failure reporting (increment 4): parse COCKATRICE_SENTRY_DSN into the parts the Windows installer
+# needs. These are CPACK_* so CPack snapshots them at include(CPack) time and substitutes them into
+# cmake/NSIS.template.in at package time; on non-Windows they are inert (only the NSIS template consumes them).
+# Set from COCKATRICE_SENTRY_DSN on every configure, so a changed or removed DSN can never leave a stale value
+# behind. Plain variables, not CACHE: they only need to be in scope when include(CPack) later snapshots every
+# CPACK_* variable into CPackConfig.cmake.
+set(CPACK_SENTRY_ENABLED "0")
+set(CPACK_SENTRY_KEY "")
+set(CPACK_SENTRY_ENVELOPE_URL "")
+set(CPACK_SENTRY_DSN_FULL "")
+# Deliberately PROJECT_VERSION: that is what the client bakes as its Sentry release and what CI passes to
+# `sentry-cli debug-files upload`, so installer and client events group under one release. It is not the
+# CPACK_PACKAGE_VERSION the installer logs in its own log line — on Development tags PROJECT_VERSION carries a
+# "-