ci: retain current build fixes in the readonly native diagnostic

This commit is contained in:
dh
2026-10-03 16:22:03 +02:00
5 changed files with 260 additions and 19 deletions
+42 -18
View File
@@ -24,27 +24,23 @@ jobs:
- name: Install Wine - name: Install Wine
run: | run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
sudo dpkg --add-architecture i386 sudo dpkg --add-architecture i386
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends wine64 wine32 winbind unzip sudo apt-get install -y --no-install-recommends ca-certificates curl gnupg winbind unzip
if [ -d /usr/lib/wine ]; then sudo install -d -m 0755 /etc/apt/keyrings
echo "/usr/lib/wine" >> "${GITHUB_PATH}" curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \
export PATH="${PATH}:/usr/lib/wine" | gpg --dearmor \
fi | sudo tee /etc/apt/keyrings/winehq-archive.key >/dev/null
if command -v wine >/dev/null 2>&1; then . /etc/os-release
WINE_BIN="$(command -v wine)" curl -fsSL "https://dl.winehq.org/wine-builds/ubuntu/dists/${VERSION_CODENAME}/winehq-${VERSION_CODENAME}.sources" \
elif command -v wine64 >/dev/null 2>&1; then | sudo tee /etc/apt/sources.list.d/winehq.sources >/dev/null
WINE_BIN="$(command -v wine64)" sudo apt-get update
elif [ -x /usr/lib/wine/wine64 ]; then # Wine 9 cannot enumerate the DOS_DOT patterns used by .NET 10's SDK pack lookup.
WINE_BIN="/usr/lib/wine/wine64" sudo apt-get install -y --no-install-recommends "winehq-stable=11.0.0.0~${VERSION_CODENAME}-1"
elif [ -x /usr/lib/wine/wine ]; then WINE_BIN="/opt/wine-stable/bin/wine"
WINE_BIN="/usr/lib/wine/wine" test -x "${WINE_BIN}"
else
echo "No wine binary found after installation."
ls -la /usr/lib/wine || true
exit 1
fi
echo "WINE_BIN=${WINE_BIN}" >> "${GITHUB_ENV}" echo "WINE_BIN=${WINE_BIN}" >> "${GITHUB_ENV}"
"${WINE_BIN}" --version "${WINE_BIN}" --version
@@ -77,6 +73,34 @@ jobs:
echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}" echo "WIN_DOTNET_DIR=${WIN_DOTNET_DIR}" >> "${GITHUB_ENV}"
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info "${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" --info
- name: Provision NuGet signature trust inside Wine
run: |
SDK_VERSION="$(dotnet --version)"
SDK_TRUST_ROOT="${DOTNET_ROOT}/sdk/${SDK_VERSION}/trustedroots"
dotnet publish scripts/wine-sdk-trust.cs \
-c Release \
-p:UseAppHost=false \
-p:PublishAot=false \
-o "${RUNNER_TEMP}/wine-sdk-trust" \
--nologo
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" \
"Z:${RUNNER_TEMP}/wine-sdk-trust/WineSdkTrust.dll" --import \
"Z:${SDK_TRUST_ROOT}/codesignctl.pem" \
"Z:${SDK_TRUST_ROOT}/timestampctl.pem"
- name: Diagnose Windows SDK targeting-pack resolution
run: |
find "${WIN_DOTNET_DIR}/packs" -maxdepth 4 -name PackageOverrides.txt -print
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
-p:EnableWindowsTargeting=true \
-p:TargetFramework=net10.0 \
-getProperty:NetCoreRoot,NetCoreTargetingPackRoot,PrunePackageDataRoot,PrunePackageTargetingPackRoots,MSBuildSDKsPath,DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" msbuild MeetingAssistant/MeetingAssistant.csproj \
-p:EnableWindowsTargeting=true \
-p:TargetFramework=net10.0 \
-t:AddPrunePackageReferences \
-v:normal
- name: Build Windows desktop target via Wine - name: Build Windows desktop target via Wine
run: | run: |
rm -f MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll rm -f MeetingAssistant/bin/Release/net10.0-windows10.0.19041.0/win-x64/MeetingAssistant.dll
+1 -1
View File
@@ -58,7 +58,7 @@
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'"> <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" /> <PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" /> <PackageReference Include="H.NotifyIcon" Version="2.4.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+26
View File
@@ -0,0 +1,26 @@
# Wine SDK certificate trust
`scripts/wine-sdk-trust.cs` is a .NET 10 file-based helper for validating the public root-certificate bundles distributed with the Microsoft SDK and importing them into a disposable Wine prefix's Windows `CurrentUser Root` store. It does not disable NuGet signature verification or certificate checks and does not import private keys.
```sh
dotnet publish scripts/wine-sdk-trust.cs -c Release -p:UseAppHost=false -p:PublishAot=false -o artifacts/wine-sdk-trust
dotnet artifacts/wine-sdk-trust/WineSdkTrust.dll --validate /path/to/sdk/trustedroots/codesignctl.pem /path/to/sdk/trustedroots/timestampctl.pem
```
Validation is read-only on every OS. Both files must exist and contain public PEM certificates. Explicit non-CA certificates are rejected. Historical self-issued SDK roots without `BasicConstraints` remain valid; certificate expiry is not filtered because trusted bundles also support historical signatures. The helper logs each bundle's certificate count and SHA-256, then the unique certificate count. Use bundles from the verified Microsoft SDK archive and retain those hashes with the SDK provenance.
For the Windows SDK installed inside a disposable Wine prefix, invoke the published DLL with `--import` and the two authoritative Linux SDK bundle paths, for example:
```sh
"${WINE_BIN}" "${WIN_DOTNET_DIR}/dotnet.exe" artifacts/wine-sdk-trust/WineSdkTrust.dll --import \
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/codesignctl.pem" \
"Z:${DOTNET_ROOT}/sdk/10.0.401/trustedroots/timestampctl.pem"
```
The import mode first requires `OperatingSystem.IsWindows()` and the Wine-specific `wine_get_version` export from `ntdll.dll`, located with `NativeLibrary.TryLoad` and `TryGetExport`. Native Windows is rejected as well as macOS/Linux. Only after both bundles validate does it open `CurrentUser Root` for writing and call `AddRange`. It closes the store, reopens it read-only and asserts that every imported thumbprint is present. There is no localized shell command, interactive prompt or GUI automation. Missing/unknown arguments and import attempts outside Wine return `2`; validation/import failures return `1`; successful validation or verified import returns `0`.
The write side effect is confined to the selected Wine prefix's current-user root store. Repeated imports are safe. Run it only against the disposable CI prefix. Native validation never opens any certificate store, and native import attempts are rejected before parsing bundles or constructing a store. The normal PR workflow publishes this DLL and imports the bundles before the Windows desktop restore/build. This repair still requires a real Wine restore/build result to establish that it fixes the observed trust failure.
[Microsoft documents](https://learn.microsoft.com/en-us/dotnet/core/tools/nuget-signed-package-verification) that these SDK bundles originate from its Trusted Root Program and provide code-signing and timestamping roots. The Windows NuGet restore remains responsible for checking actual package signatures; this helper populates the Wine store used for those checks.
Local qualification on 2026-10-03 used SDK 10.0.203 to publish the DLL and the authoritative bundles from SDK 10.0.401 to validate it on macOS/.NET 10.0.7. Both bundles passed: codesign contained 307 certificates including seven historical roots without constraints; timestamp contained 327 including two such roots; the union contained 372 unique thumbprints. Their SHA-256 hashes were `AAB671F52E5229906B2100727370007EF4B6D2E360B23F2CF12A6D87773BE611` and `5CCB03367B52F047099F07E1653160E8578A83711CB18560C979FEBB65F8CB5D`, respectively. A native `--import` invocation with those same paths returned `2` before parsing bundles or opening a store; unknown arguments returned `2`, and an empty input returned `1`. No local store import was performed. This is helper qualification, not a passing Wine/NuGet result.
+41
View File
@@ -0,0 +1,41 @@
# Windows desktop build under Wine
The PR workflow uses the existing `ubuntu-latest` Gitea runner. It builds `net10.0-windows10.0.19041.0` with the Windows .NET SDK under Wine, then runs the portable `net10.0` test suite through that Windows host. The desktop target retains WPF, `WinExe`, x64, and the Windows recording, hotkey, Outlook, screenshot, tray, and notification implementations.
## Observed failures
On 2026-10-03, run 4154 at commit `05e23857ddf9e6990b76660e2ef5c1b429bfe66b` used Wine 11 and successfully imported and read back all 372 unique thumbprints from the Microsoft SDK's code-signing and timestamp trust bundles. The Windows SDK targeting-pack diagnostic succeeded, and the Windows desktop restore completed. NuGet signature verification was not disabled. See [the trust helper documentation](wine-sdk-trust.md).
The desktop build then failed in `WinAppSdkExpandPriContent` from `Microsoft.Windows.SDK.BuildTools.MSIX` 1.7.20250829.1. MakePRI reported `PRI175: 0x80004001 - Dump`, `MakePri failed with error: Not implemented`, and `PRI222`. The following `Root element is missing` exception came from loading the missing dump output; it was a secondary failure. These logs establish a MakePRI compatibility failure under Wine, but do not identify the particular unimplemented Wine API.
## Dependency correction
The resolved Windows graph was:
```text
H.NotifyIcon.Uno.WinUI 2.4.1
-> H.NotifyIcon 2.4.1
-> Microsoft.WindowsAppSDK 1.8.251106002
-> Microsoft.WindowsAppSDK.Base 1.8.250831001
-> Microsoft.Windows.SDK.BuildTools.MSIX 1.7.20250829.1
```
`UnoTaskbarIconService.Windows.cs` uses `H.NotifyIcon.Core.TrayIconWithContextMenu`, `PopupMenu`, `PopupMenuItem`, `PopupMenuSeparator`, and `PopupSubMenu`. These types come from the already-selected `H.NotifyIcon` 2.4.1 assembly. The application does not use the wrapper's WinUI/Uno XAML controls. The Windows package reference therefore selects `H.NotifyIcon` 2.4.1 directly. This keeps the same core assembly and tray APIs while removing the unused WinUI wrapper and its Windows App SDK graph. `CommunityToolkit.WinUI.Notifications` 7.1.2 remains available for toast notifications and does not introduce Windows App SDK in the selected Windows target.
The package author's [core-package documentation](https://www.nuget.org/packages/H.NotifyIcon/2.4.1) supports using `H.NotifyIcon` directly, including in console applications. Its `net10.0` dependency group contains `H.GeneratedIcons.System.Drawing` 2.4.1. The [wrapper package](https://www.nuget.org/packages/H.NotifyIcon.Uno.WinUI/2.4.1) adds Windows App SDK for its Windows target.
PRI expansion discovers referenced asset files that are absent from normal project outputs and adds them to copy-local output. Disabling that target would risk losing real WinUI resource payloads. This correction removes an unused application dependency instead of disabling PRI generation/expansion, creating a dummy PRI file, suppressing signature checks, or changing the desktop target.
## Qualification and remaining CI evidence
Local qualification on 2026-10-03 used the existing `mcr.microsoft.com/dotnet/sdk:10.0-noble` container image with SDK 10.0.401. The existing NuGet package cache was a read-only fallback; new package/cache writes went to a separate temporary directory. This command inside the container compiled the complete Windows desktop target:
```sh
dotnet build MeetingAssistant/MeetingAssistant.csproj \
-c Release -f net10.0-windows10.0.19041.0 -r win-x64 \
-p:EnableWindowsTargeting=true -p:RestoreFallbackFolders=/host-nuget --nologo
```
The command returned zero with `Build succeeded`, zero errors, and four existing NAudio deprecation warnings. The produced runtime configuration retains `Microsoft.WindowsDesktop.App` alongside the .NET and ASP.NET frameworks. The regenerated assets graph contains `H.NotifyIcon/2.4.1`, no `Microsoft.WindowsAppSDK*` packages, and no `Microsoft.Windows.SDK.BuildTools.MSIX`; generated package imports contain no Windows App SDK or MakePRI entry. The output `H.NotifyIcon.dll` is byte-identical to the previously selected cached core assembly, with SHA-256 `0027e443a6121af8fb616c0200d3c63bf4abb72e3c548e119fee1eae321a8368`. No application source or target suppression was required. This Linux cross-build does not establish that Windows `dotnet.exe` succeeds under Wine.
Completion requires a new Gitea run at the corrected commit: successful Windows desktop build under Wine with a freshly produced `MeetingAssistant.dll`, followed by actual Windows-host test execution with a fresh `wine.trx`, nonzero executed tests, zero failed tests, and a successful job. The workflow removes the previous DLL/TRX at the expected paths before these commands and requires nonempty replacements. The Wine test project targets `net10.0`, so these tests do not prove execution of Windows-TFM-only or WPF/Outlook UI behavior. Native macOS tests report their explicit platform skip outside macOS.
+150
View File
@@ -0,0 +1,150 @@
#:property PublishAot=false
#:property UseAppHost=false
#:property AssemblyName=WineSdkTrust
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
if (args.Length != 3 || (args[0] != "--validate" && args[0] != "--import"))
{
Console.Error.WriteLine("Usage: WineSdkTrust <--validate|--import> <codesignctl.pem> <timestampctl.pem>");
return 2;
}
var import = args[0] == "--import";
if (import && (!OperatingSystem.IsWindows() || !IsWine()))
{
Console.Error.WriteLine("REFUSED: --import requires Windows under Wine (ntdll.dll!wine_get_version). No certificate store was opened.");
return 2;
}
Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"Mode: {args[0]}");
var certificates = new X509Certificate2Collection();
try
{
foreach (var path in args.Skip(1))
{
if (!File.Exists(path) || new FileInfo(path).Length == 0)
{
throw new InvalidDataException($"The SDK certificate bundle is missing or empty: {path}");
}
var bundle = new X509Certificate2Collection();
try
{
bundle.ImportFromPemFile(path);
if (bundle.Count == 0)
{
throw new InvalidDataException($"The SDK bundle contains no PEM certificates: {path}");
}
var legacyRoots = 0;
foreach (var certificate in bundle)
{
if (certificate.HasPrivateKey)
{
throw new InvalidDataException($"The SDK bundle must contain public certificates only: {certificate.Thumbprint}");
}
var constraints = certificate.Extensions.OfType<X509BasicConstraintsExtension>().SingleOrDefault();
if (constraints is { CertificateAuthority: false })
{
throw new InvalidDataException($"The SDK bundle contains a non-CA certificate: {certificate.Thumbprint}");
}
if (constraints is null)
{
// Microsoft also ships historical roots without the BasicConstraints extension.
if (!certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData))
{
throw new InvalidDataException($"A certificate without CA constraints is not self-issued: {certificate.Thumbprint}");
}
legacyRoots++;
}
}
Console.WriteLine($"Bundle: {Path.GetFullPath(path)}");
Console.WriteLine($" SHA256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))}");
Console.WriteLine($" Certificates: {bundle.Count}; historical self-issued roots without BasicConstraints: {legacyRoots}");
certificates.AddRange(bundle);
bundle.Clear();
}
finally
{
foreach (var certificate in bundle)
{
certificate.Dispose();
}
}
}
var thumbprints = certificates.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"Unique SDK certificate thumbprints: {thumbprints.Count}");
if (!import)
{
Console.WriteLine("PASS: both SDK bundles validated; no certificate store was opened.");
return 0;
}
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadWrite);
store.AddRange(certificates);
}
using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadOnly);
var installed = store.Certificates;
try
{
var installedThumbprints = installed.Select(certificate => certificate.Thumbprint).ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = thumbprints.Except(installedThumbprints).ToArray();
if (missing.Length != 0)
{
throw new CryptographicException($"SDK certificates missing after import: {string.Join(", ", missing)}");
}
}
finally
{
foreach (var certificate in installed)
{
certificate.Dispose();
}
}
}
Console.WriteLine($"PASS: all {thumbprints.Count} SDK certificate thumbprints verified in CurrentUser Root.");
return 0;
}
catch (Exception exception)
{
Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}");
return 1;
}
finally
{
foreach (var certificate in certificates)
{
certificate.Dispose();
}
}
static bool IsWine()
{
if (!NativeLibrary.TryLoad("ntdll.dll", out var library))
{
return false;
}
try
{
return NativeLibrary.TryGetExport(library, "wine_get_version", out _);
}
finally
{
NativeLibrary.Free(library);
}
}