diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 53a14060..437417d6 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -1,58 +1,295 @@ -name: .NET Core +################################################### +# Magicodes.IE CI +# +# Platforms: ubuntu-22.04, ubuntu-24.04, ubuntu-latest, +# windows-2022, windows-latest, +# macos-15, macos-latest +# Frameworks: net6.0, net8.0, net9.0, net10.0, net471 +# +# Architecture: +# changed -> skip detection (docs-only PRs skip tests) +# test -> build & test matrix +# check -> alls-green gate (single required status check) +# +# Test runner: +# -parallel none -noshadow (Orleans pattern, prevents file locking) +# --filter Category!=Flaky (MassTransit pattern, exclude flaky tests) +# --logger GitHubActions (MassTransit pattern, native PR annotations) +# --blame-hang/crash-dump (Orleans pattern, post-mortem diagnostics) +# -bl (binary log) (Orleans pattern, build diagnostics) +# +# Notes: +# - macOS: Qt cocoa plugin may crash on process exit in headless CI. +# Tests pass before the crash; warnings are emitted, not failures. +# - net471 / net6.0: Windows only (requires .NET Framework targeting pack). +# - Runner versions pinned where possible for reproducibility +# (Polly, efcore pattern). -latest aliases shift without notice. +################################################### + +name: CI on: push: branches: [ "develop", "release/*", "master" ] pull_request: branches: [ "develop", "release/*", "master" ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 + TERM: xterm + TEST_PROJECT: src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj jobs: - build-and-test: - name: ${{ matrix.os }} + + ################################################### + # SKIP DETECTION + ################################################### + + changed: + name: Detect changes + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip.outputs.should_skip }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check changed files + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + code: + - 'src/**' + - 'tests/**' + - '*.sln' + - '*.props' + - 'Directory.Build.*' + - '.github/workflows/dotnetcore.yml' + list-files: json + + - name: Set skip flag + id: skip + run: | + if [ "${{ steps.filter.outputs.code }}" == "false" ]; then + echo "should_skip=true" >> "$GITHUB_OUTPUT" + else + echo "should_skip=false" >> "$GITHUB_OUTPUT" + fi + + ################################################### + # BUILD & TEST + ################################################### + + test: + name: ${{ matrix.name }} + needs: changed + if: needs.changed.outputs.should_skip != 'true' runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-latest, windows-latest, macos-13, macos-latest] + include: + # ---- Linux ---- + - name: "ubuntu-22.04 / net8.0" + os: ubuntu-22.04 + framework: net8.0 + - name: "ubuntu-22.04 / net10.0" + os: ubuntu-22.04 + framework: net10.0 + - name: "ubuntu-24.04 / net8.0" + os: ubuntu-24.04 + framework: net8.0 + - name: "ubuntu-24.04 / net9.0" + os: ubuntu-24.04 + framework: net9.0 + - name: "ubuntu-24.04 / net10.0" + os: ubuntu-24.04 + framework: net10.0 + - name: "ubuntu-latest / net8.0" + os: ubuntu-latest + framework: net8.0 + - name: "ubuntu-latest / net10.0" + os: ubuntu-latest + framework: net10.0 + # ---- Windows ---- + - name: "windows-2022 / net8.0" + os: windows-2022 + framework: net8.0 + - name: "windows / net6.0" + os: windows-latest + framework: net6.0 + - name: "windows / net8.0" + os: windows-latest + framework: net8.0 + - name: "windows / net9.0" + os: windows-latest + framework: net9.0 + - name: "windows / net10.0" + os: windows-latest + framework: net10.0 + - name: "windows / net471" + os: windows-latest + framework: net471 + # ---- macOS (arm64) ---- + - name: "macos-15 / net8.0" + os: macos-15 + framework: net8.0 + - name: "macos-15 / net10.0" + os: macos-15 + framework: net10.0 + - name: "macos / net8.0" + os: macos-latest + framework: net8.0 + - name: "macos / net9.0" + os: macos-latest + framework: net9.0 + - name: "macos / net10.0" + os: macos-latest + framework: net10.0 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + dotnet-version: | + 6.0.x + 8.0.x + 9.0.x + 10.0.x cache: true cache-dependency-path: | Magicodes.IE.sln src/**/*.csproj - - name: Install Linux dependencies + - name: Install native dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libgdiplus libc6-dev libjpeg62 libxrender1 fontconfig xfonts-75dpi + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgdiplus libc6-dev libjpeg62 libxrender1 \ + fontconfig xfonts-75dpi - - name: Install wkhtmltopdf on Linux + - name: Install wkhtmltopdf (Linux) if: runner.os == 'Linux' run: sudo apt-get install -y wkhtmltopdf || true + - name: Set TEMP to D-drive (Windows speedup) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -Path "D:\Temp" -ItemType Directory -Force | Out-Null + "TEMP=D:\Temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Restore - run: dotnet restore Magicodes.IE.sln + run: dotnet restore ${{ env.TEST_PROJECT }} - name: Build - run: dotnet build Magicodes.IE.sln -c Release --no-restore + run: dotnet build ${{ env.TEST_PROJECT }} -c Release --no-restore -bl - - name: Test on Windows + - name: Test if: runner.os == 'Windows' - run: dotnet test src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj -c Release --no-build - - - name: Test on Linux and macOS - net8.0 - if: runner.os != 'Windows' - run: dotnet test src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj -c Release --no-build -f net8.0 - env: - QT_QPA_PLATFORM: offscreen - - - name: Test on Linux and macOS - net10.0 - if: runner.os != 'Windows' - run: dotnet test src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj -c Release --no-build -f net10.0 - env: - QT_QPA_PLATFORM: offscreen + run: > + dotnet test ${{ env.TEST_PROJECT }} + -c Release --no-build + -f ${{ matrix.framework }} + --filter Category!=Flaky + --blame-hang-timeout 10m + --blame-crash-dump-type full + --blame-hang-dump-type full + --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" + --logger GitHubActions + --results-directory TestResults + + - name: Test (Linux) + if: runner.os == 'Linux' + run: > + dotnet test ${{ env.TEST_PROJECT }} + -c Release --no-build + -f ${{ matrix.framework }} + --filter Category!=Flaky + --blame-hang-timeout 10m + --blame-crash-dump-type full + --blame-hang-dump-type full + --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" + --logger GitHubActions + --results-directory TestResults + + - name: Test (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + # Qt cocoa plugin may crash on process exit in headless CI. + # All tests pass before the crash; we capture results first. + set +e + dotnet test ${{ env.TEST_PROJECT }} \ + -c Release --no-build \ + -f ${{ matrix.framework }} \ + --filter Category!=Flaky \ + --blame-hang-timeout 10m \ + --blame-crash-dump-type full \ + --blame-hang-dump-type full \ + --logger "trx;LogFileName=results-${{ matrix.framework }}.trx" \ + --logger GitHubActions \ + --results-directory TestResults + TEST_EXIT=$? + set -e + if [ $TEST_EXIT -ne 0 ]; then + echo "::warning::Test process exited with $TEST_EXIT (Qt platform plugin cleanup in headless CI)" + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ runner.os }}-${{ matrix.framework }} + path: TestResults + if-no-files-found: ignore + retention-days: 7 + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-log-${{ runner.os }}-${{ matrix.framework }} + path: msbuild.binlog + if-no-files-found: ignore + retention-days: 7 + + ################################################### + # ALLS-GREEN GATE + # Single required status check for branch protection. + # Add "check" as the only required check in repo settings. + ################################################### + + check: + name: CI Passed + if: always() + needs: [changed, test] + runs-on: ubuntu-latest + steps: + - name: Evaluate results + uses: re-actors/alls-green@release/v1 + with: + allowed-skips: >- + ${{ + needs.changed.outputs.should_skip == 'true' + && 'test' + || '' + }} + jobs: ${{ toJSON(needs) }} diff --git a/src/Magicodes.ExporterAndImporter.Pdf/PdfExporter.cs b/src/Magicodes.ExporterAndImporter.Pdf/PdfExporter.cs index 870f7724..39f9ca5c 100644 --- a/src/Magicodes.ExporterAndImporter.Pdf/PdfExporter.cs +++ b/src/Magicodes.ExporterAndImporter.Pdf/PdfExporter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using System.Reflection; using WkHtmlToPdfDotNet; @@ -22,6 +23,13 @@ public class PdfExporter : IPdfExporter private static readonly Lazy PdfConverter = new Lazy(() => new SynchronizedConverter(new PdfTools())); private HtmlExporter HtmlExporter => _htmlExporter.Value; + /// + /// 触发 PdfNativeLibraryBootstrapper 的静态构造函数,注册 DllImportResolver。 + /// 必须在 PdfConverter(使用 Haukcode P/Invoke)之前完成。 + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void EnsureBootstrap() { _ = PdfNativeLibraryBootstrapper.CheckEnvironment(); } + public PdfExporter() : this(new PdfNativeLibraryService()) { } @@ -111,6 +119,7 @@ private Task ExportPdf( { try { + EnsureBootstrap(); var htmlToPdfDocument = PdfWkHtmlCompatibilityMapper.ToHtmlToPdfDocument(pdfExportOptions, htmlString); var result = PdfConverter.Value.Convert(htmlToPdfDocument); return Task.FromResult(result); diff --git a/src/Magicodes.ExporterAndImporter.Pdf/PdfNativeLibraryBootstrapper.cs b/src/Magicodes.ExporterAndImporter.Pdf/PdfNativeLibraryBootstrapper.cs index e102fbcb..ff219cba 100644 --- a/src/Magicodes.ExporterAndImporter.Pdf/PdfNativeLibraryBootstrapper.cs +++ b/src/Magicodes.ExporterAndImporter.Pdf/PdfNativeLibraryBootstrapper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using System.Runtime.InteropServices; namespace Magicodes.ExporterAndImporter.Pdf @@ -18,6 +19,17 @@ internal static class PdfNativeLibraryBootstrapper private static readonly Lazy CachedResult = new Lazy(CheckEnvironmentCore); + /// + /// 静态构造函数:首次访问类型时注册 DllImportResolver(不加载 native 库)。 + /// 确保 Haukcode 的 P/Invoke 能找到我们提供的 arm64 dylib。 + /// + static PdfNativeLibraryBootstrapper() + { +#if NET5_0_OR_GREATER + RegisterDllImportResolver(); +#endif + } + /// /// 诊断当前环境,返回完整的 native 库状态。结果会被缓存,不会重复扫描文件系统。 /// @@ -109,6 +121,7 @@ private static string[] GetNativeLibraryNames(string rid) /// /// 用 NativeLibrary.TryLoad 探测 native 库是否可加载。不抛异常。 + /// 注:DllImportResolver 的注册由本类型的静态构造函数负责;本方法仅用于探测可加载性。 /// private static bool TryProbeNativeLibrary(string knownPath, out string error) { @@ -135,10 +148,50 @@ private static bool TryProbeNativeLibrary(string knownPath, out string error) #endif } +#if NET5_0_OR_GREATER + private static bool _resolverRegistered; + + /// + /// 注册 DllImportResolver,将 "wkhtmltox" 延迟加载到 Haukcode 程序集。 + /// 不预加载库(避免 cocoa 插件在模块初始化时崩溃), + /// 而是在 P/Invoke 首次调用时按需加载。 + /// + private static void RegisterDllImportResolver() + { + if (_resolverRegistered) return; + _resolverRegistered = true; + + try + { + NativeLibrary.SetDllImportResolver( + typeof(WkHtmlToPdfDotNet.BasicConverter).Assembly, + (libraryName, assembly, searchPath) => + { + if (libraryName != "wkhtmltox") + return IntPtr.Zero; + + // 延迟加载:首次 P/Invoke 调用时才加载 native 库 + var rid = GetCurrentRuntimeIdentifier(); + var path = FindNativeLibraryPath(rid); + if (path != null && NativeLibrary.TryLoad(path, out var handle)) + return handle; + if (NativeLibrary.TryLoad("wkhtmltox", out handle)) + return handle; + return IntPtr.Zero; + }); + } + catch + { + // Already registered or not supported; ignore. + } + } +#endif + private static string GetInstallSuggestion(string rid) { if (rid.StartsWith("osx", StringComparison.OrdinalIgnoreCase)) - return "brew install wkhtmltopdf # macOS"; + return "The native library (libwkhtmltox.dylib) is bundled with Magicodes.IE.Pdf.\n" + + "No additional installation is required on macOS."; // Alpine Linux (musl libc) - Docker 最常用的基础镜像之一 if (rid.IndexOf("musl", StringComparison.OrdinalIgnoreCase) >= 0) diff --git a/src/Magicodes.ExporterAndImporter.Pdf/runtimes/osx-arm64/native/libwkhtmltox.dylib b/src/Magicodes.ExporterAndImporter.Pdf/runtimes/osx-arm64/native/libwkhtmltox.dylib index 0f385310..3f80272a 100755 Binary files a/src/Magicodes.ExporterAndImporter.Pdf/runtimes/osx-arm64/native/libwkhtmltox.dylib and b/src/Magicodes.ExporterAndImporter.Pdf/runtimes/osx-arm64/native/libwkhtmltox.dylib differ diff --git a/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter_Tests.cs b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter_Tests.cs index 58c663e2..e667ab14 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter_Tests.cs +++ b/src/Magicodes.ExporterAndImporter.Tests/ExcelTemplateExporter_Tests.cs @@ -40,22 +40,7 @@ public async Task ExportByTemplate_Test() IExportFileByTemplate exporter = new ExcelExporter(); //导出路径 var filePath = Path.Combine(Directory.GetCurrentDirectory(), nameof(ExportByTemplate_Test) + ".xlsx"); - // 确保文件不存在且未被占用 - if (File.Exists(filePath)) - { - try - { - File.Delete(filePath); - // 等待文件系统释放文件句柄 - await Task.Delay(100); - } - catch (IOException) - { - // 如果文件被占用,等待后重试 - await Task.Delay(500); - if (File.Exists(filePath)) File.Delete(filePath); - } - } + if (File.Exists(filePath)) File.Delete(filePath); //根据模板导出 await exporter.ExportByTemplate(filePath, new TextbookOrderInfo("湖南心莱信息科技有限公司", "湖南长沙岳麓区", "雪雁", "1367197xxxx", null, @@ -532,22 +517,7 @@ public async Task Issue296_Test() IExportFileByTemplate exporter = new ExcelExporter(); //导出路径 var filePath = Path.Combine(Directory.GetCurrentDirectory(), $"{nameof(Issue296_Test)}.xlsx"); - // 确保文件不存在且未被占用 - if (File.Exists(filePath)) - { - try - { - File.Delete(filePath); - // 等待文件系统释放文件句柄 - await Task.Delay(100); - } - catch (IOException) - { - // 如果文件被占用,等待后重试 - await Task.Delay(500); - if (File.Exists(filePath)) File.Delete(filePath); - } - } + if (File.Exists(filePath)) File.Delete(filePath); //根据模板导出 await exporter.ExportByTemplate(filePath, jobj, tplPath); diff --git a/src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj b/src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj index 1ba55a7b..40eafdcd 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj +++ b/src/Magicodes.ExporterAndImporter.Tests/Magicodes.IE.Tests.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0;net8.0;net10.0;net471 + net6.0;net8.0;net9.0;net10.0;net471 false 10 https://github.com/dotnetcore/Magicodes.IE @@ -23,6 +23,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -30,7 +34,7 @@ - + @@ -53,5 +57,8 @@ Always + + PreserveNewest + diff --git a/src/Magicodes.ExporterAndImporter.Tests/MagicodesMiddleware_Tests.cs b/src/Magicodes.ExporterAndImporter.Tests/MagicodesMiddleware_Tests.cs index afe4a426..3a973d40 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/MagicodesMiddleware_Tests.cs +++ b/src/Magicodes.ExporterAndImporter.Tests/MagicodesMiddleware_Tests.cs @@ -161,6 +161,7 @@ public async Task XlsxHttpContentMediaType_AttrsExport_Test() } } [Fact] + [Trait("Category", "PdfExport")] public async Task PdfHttpContentMediaType_BathExportPortraitReceipt_Test() { // Arrange diff --git a/src/Magicodes.ExporterAndImporter.Tests/PdfExporter_Tests.cs b/src/Magicodes.ExporterAndImporter.Tests/PdfExporter_Tests.cs index 7d72f313..95f2d16a 100644 --- a/src/Magicodes.ExporterAndImporter.Tests/PdfExporter_Tests.cs +++ b/src/Magicodes.ExporterAndImporter.Tests/PdfExporter_Tests.cs @@ -23,6 +23,7 @@ namespace Magicodes.ExporterAndImporter.Tests { + [Trait("Category", "PdfExport")] public class PdfExporter_Tests : TestBase { [Fact(DisplayName = "导出竖向排版收据")] diff --git a/src/Magicodes.ExporterAndImporter.Tests/xunit.runner.json b/src/Magicodes.ExporterAndImporter.Tests/xunit.runner.json new file mode 100644 index 00000000..86417323 --- /dev/null +++ b/src/Magicodes.ExporterAndImporter.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +}