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)
# exitcode: 1 for failure, 3 for invalid arguments
# Read arguments
@@ -115,6 +116,10 @@ while [[ $# != 0 ]]; do
export CMAKE_GENERATOR=$1
shift
;;
+ '--profile')
+ BUILD_PROFILE=1
+ shift
+ ;;
'--target-macos-version')
shift
if [[ $# == 0 ]]; then
@@ -170,9 +175,9 @@ fi
if [[ $MAKE_SANITIZE ]]; 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
@@ -180,9 +185,6 @@ if [[ $MAKE_COVERAGE ]]; then
fi
if [[ $USE_CCACHE ]]; 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
ccache --max-size "$CCACHE_SIZE"
@@ -195,6 +197,25 @@ if [[ $USE_VCPKG ]]; 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")
@@ -209,6 +230,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,
@@ -243,8 +280,14 @@ if [[ $RUNNER_OS == macOS ]]; 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"
+ # 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")
@@ -270,16 +313,24 @@ if [[ $RUNNER_OS == macOS ]]; then
if [[ $MAKE_PACKAGE ]]; 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,9 +338,34 @@ 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 [[ ! $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
@@ -304,11 +380,35 @@ echo "Running cmake with flags: ${flags[*]}"
cmake .. "${flags[@]}"
echo "::endgroup::"
+if [[ $USE_CCACHE ]]; 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
+
echo "::group::Build project"
+cpuinfo
echo "Running cmake --build with flags: ${buildflags[*]}"
cmake --build . "${buildflags[@]}"
echo "::endgroup::"
+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 ]]; then
if [[ $CCACHE_EVICTION_AGE ]]; then
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
@@ -384,6 +484,206 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
fi
+if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; 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
+
if [[ $MAKE_INSTALL ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"
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/.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..b859556aa 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -33,10 +33,18 @@ on:
- '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' }}
jobs:
@@ -46,6 +54,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 +69,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 +109,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 +149,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: 660 MB of objects at -g1
- distro: Servatrice_Debian
version: 12
@@ -110,88 +158,159 @@ 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: 614 MB of objects 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 # The sanitizer leg below runs this distro's suite
+ # 18.1 min, and the same distro as the sanitize leg below, which runs this suite.
+ persist_ccache: 'false'
+
+ # The one leg that runs the suite under AddressSanitizer and
+ # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the
+ # uninstrumented suite, so nothing is lost by instrumenting this one.
+ #
+ # It packages nothing. A sanitized Debug build is not something to ship, and
+ # carrying the package build here meant 18 minutes of Release compile queued
+ # behind the instrumented one in a single job. The leg above publishes this
+ # distro's DEB.
+ - distro: Ubuntu
+ version: 26.04
+ variant: sanitize # separate image and ccache, same distro
+
+ package: skip
+ sanitize: yes
+ ccache_size: 1200M # instrumented objects are larger than the 614 MB measured; estimated
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: the quota also holds about 0.9 GB of buildkit
+ # layers (mode=min in docker-release.yml), 0.8 GB of vcpkg, 0.7 GB of Qt and the CodeQL
+ # trap caches (about 1 GB each, which prune-caches below does not manage).
+ #
+ # 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 660 MB working set 850M
+ # Debian 13 Release + suite 614 MB 800M
+ # Fedora 45 debug Debug + suite 660 MB 850M
+ # Fedora 45 release Release + package 614 MB 800M
+ # Ubuntu 26.04 sanitize ~1.0 GB 1200M
+ #
+ # That is about 5.3 GB of bounds, and with the fixed entries above it puts the quota at
+ # roughly 9.8 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 the sanitize leg of
+ # the same distro; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the
+ # server alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and
+ # 15.8 min. The five 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).
+ #
+ # 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 +344,105 @@ 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[@]}"
# 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 +453,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,94 +495,178 @@ 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 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 one instrument that only reports.
+ 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: 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
-
- 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
+ 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: 14
package_suffix: "-macOS14"
+ ccache_size: 800M # one Release configuration plus the suite at -g1
qt_version: 6.11.*
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
package_suffix: "-macOS15"
+ persist_ccache: 'false' # 16.5 min: shorter than the Windows leg
qt_version: 6.11.*
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
+ persist_ccache: 'false' # 15.8 min: shorter than the Windows leg
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
+ # One macOS configuration is about 600 MB of objects at -g1, so its bound needs to be
+ # 800M to hold one whole; a smaller cache serves a few percent and is worthless (see
+ # the sizing comment in build-linux). macOS 14 keeps the one macOS cache: it runs the
+ # suite against the shipping Release build and is the longest of the three at 24.2 min.
+ # macOS 15 (16.5) and macOS 27 Debug (15.8) duplicate its coverage and are shorter, so
+ # they run cold.
+ CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
+ PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
+ # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler
+ # cache to shrink anyway (see the sizing comment in build-linux).
+ 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:
@@ -409,11 +683,11 @@ jobs:
run: brew install ccache
- name: "[macOS] Restore compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1
+ if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
id: restore_ccache
uses: actions/cache/restore@v6
with:
- key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
+ key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }}
path: ${{ env.CCACHE_DIR }}
- name: "[macOS] Install aqtinstall"
@@ -476,12 +750,45 @@ jobs:
shell: bash
run: choco install nsis
+ # 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
@@ -490,8 +797,11 @@ jobs:
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 }}
@@ -501,11 +811,138 @@ jobs:
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,9 +954,66 @@ jobs:
shell: bash
run: find . -name NSISOutput.log -exec cat {} +
+ # 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_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
+ GH_TOKEN: ${{ github.token }}
+ VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
+ run: |
+ # 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"
+
+ 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:
+ 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
+
# 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
+ if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
continue-on-error: true
env:
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@@ -530,7 +1024,7 @@ jobs:
fi
- name: "[macOS] Cache updated compiler cache (ccache)"
- if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master'
+ if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
uses: actions/cache/save@v6
with:
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
@@ -592,17 +1086,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 +1097,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 +1139,133 @@ 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
+
+ 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
+ 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
+ env:
+ CCACHE_DIR: ${{ github.workspace }}/.cache/
+ # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with
+ # the pull-request legs (see PERSIST_CCACHE there).
+ PERSIST_CCACHE: 'false'
+ CCACHE_SIZE: 600M
+ 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.
+ - 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 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27'
+ 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} "
+
+ 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), so that an entry of some other workflow can
+ # never be deleted by accident
+ case "$key" in
+ vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;;
+ *) 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-Ubuntu26.04 is a prefix of ccache-Ubuntu26.04sanitize, and only
+ # the sanitize entry is 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
+ 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/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/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/.gitmodules b/.gitmodules
index 732fec7ca..e69c8e686 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -4,3 +4,6 @@
[submodule "doxygen-awesome-css"]
path = doc/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..53508fcd1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
+# 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."
+ )
+ set(WITH_GUI_TEST OFF)
+endif()
+
# Default to "Release" build type
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
if(DEFINED CMAKE_BUILD_TYPE)
@@ -55,6 +65,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 +85,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,7 +106,7 @@ 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
if(NOT DEFINED GIT_TAG_RELEASENAME)
@@ -220,6 +236,28 @@ endif()
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
include(Sanitizers)
include(Coverage)
+include(CrashReporting)
+
+# 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 the RNG to compile w/o warning
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
@@ -235,13 +273,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 +287,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)
@@ -327,7 +380,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 +389,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 +472,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 +486,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 +533,5 @@ 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()
diff --git a/README.md b/README.md
index f2a170868..fa9272c0f 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/.github/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,7 +96,7 @@ 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/)
@@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta
- [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)
-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/)
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
+# "-