From 6066bf01f86a746bcd6f73900d2546d9e200b6ea Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 26 Aug 2026 21:31:04 +1000 Subject: [PATCH] ci: adopt the unified dotnet workflow [patch] Replaces the single Windows job with the shape ImGuiApp proved out: a discover job that enumerates test projects, one test job per platform, then release and security. Tests now run on Linux as well as Windows, which is new coverage for a library that ships netstandard and claims to run anywhere but was only ever tested on one operating system. The winget job is gone. The workflow also passes --exclude "**/*.UITests/*" on Windows, which matches nothing here and is reported as such. It is a standing convention so that a repository growing UI tests later gets the right behavior without another workflow edit. --- .github/workflows/dotnet.yml | 303 ++++++++++++++++++++++++++++------- 1 file changed, 248 insertions(+), 55 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 00c919a..51a5f26 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -6,6 +6,8 @@ on: paths-ignore: ["**.md", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md"] pull_request: + paths-ignore: + ["**.md", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md"] schedule: - cron: "0 23 * * *" # Daily at 11 PM UTC workflow_dispatch: # Allow manual triggers @@ -33,10 +35,198 @@ env: DOTNET_VERSION: "10.0" # Only needed for actions/setup-dotnet jobs: - build: - name: Build, Test & Release + discover: + name: Discover Test Projects + runs-on: ubuntu-latest + timeout-minutes: 10 + + outputs: + matrix: ${{ steps.discover.outputs.matrix }} + platforms: ${{ steps.discover.outputs.platforms }} + has_tests: ${{ steps.discover.outputs.has_tests }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + + - name: Install KtsuBuild + shell: bash + run: | + dotnet tool install ktsu.KtsuBuild.Tool --tool-path "${{ runner.temp }}/ktsubuild" + echo "${{ runner.temp }}/ktsubuild" >> "$GITHUB_PATH" + + # `test list` reports every test project regardless of the host it runs on, unlike the + # filter `build` and `ci` apply, so one Linux job can enumerate cells that Windows and + # macOS runners will execute. It writes failures to stdout rather than stderr, so the + # exit code is the only reliable signal and has to be checked before parsing. + - name: Discover Test Projects + id: discover + shell: bash + run: | + set -euo pipefail + + if ! projects=$(ktsubuild test list --workspace "$GITHUB_WORKSPACE"); then + echo "::error::ktsubuild test list failed:" + echo "$projects" + exit 1 + fi + + echo "Discovered test projects:" + echo "$projects" | jq . + + # An unrecognized platform must stop the run rather than drop the project. Dropping + # it would produce a smaller matrix that still reports success, which is the failure + # this design exists to remove. + unknown=$(echo "$projects" | jq -r '[.[] | select(.platform as $p | ["neutral","windows"] | index($p) | not) | .platform] | unique | join(", ")') + if [ -n "$unknown" ]; then + echo "::error::Cannot place test project(s) on a runner. Unhandled platform(s): $unknown" + echo "::error::macOS is currently excluded from the matrix, so an ios-tied test project has nowhere to run." + exit 1 + fi + + # macOS is deliberately absent from this mapping. A macOS runner builds any project + # whose target frameworks are widened on that host, and in a repo with an iOS head that + # pulls in a target framework needing a workload this job does not install, so every + # macOS cell fails during its build. Restoring the workload on each cell is slow and + # macOS runner minutes are billed at a premium, so the platform is excluded until the + # underlying problem is fixed rather than papered over. An ios-tied test project now + # fails the guard above instead of silently finding no runner. + matrix=$(echo "$projects" | jq -c ' + { + include: [ + .[] + | . as $p + | { + neutral: ["ubuntu-latest", "windows-latest"], + windows: ["windows-latest"] + }[$p.platform][] + | { + os: ., + project: $p.project, + name: ($p.project | split("/") | last | rtrimstr(".csproj")), + slug: ($p.project | rtrimstr(".csproj") | gsub("[^A-Za-z0-9]"; "-")) + } + ] + }') + + count=$(echo "$matrix" | jq '.include | length') + echo "Matrix has $count cell(s)." + echo "$matrix" | jq . + + # The distinct hosts the cells land on. One test job runs per platform and builds once, + # so this is what that job fans out over, while `matrix` tells each job which projects + # are its own. + platforms=$(echo "$matrix" | jq -c '[.include[].os] | unique') + echo "Platforms: $platforms" + + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "platforms=$platforms" >> "$GITHUB_OUTPUT" + if [ "$count" -gt 0 ]; then + echo "has_tests=true" >> "$GITHUB_OUTPUT" + else + echo "has_tests=false" >> "$GITHUB_OUTPUT" + fi + + test: + name: Test on ${{ matrix.os }} + needs: discover + if: needs.discover.outputs.has_tests == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + + strategy: + # One platform's failure must not cancel the others. Knowing that a project fails on one + # host only is the point of testing on more than one. + fail-fast: false + matrix: + os: ${{ fromJson(needs.discover.outputs.platforms) }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + lfs: true + submodules: recursive + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Install KtsuBuild + shell: bash + run: | + dotnet tool install ktsu.KtsuBuild.Tool --tool-path "${{ runner.temp }}/ktsubuild" + echo "${{ runner.temp }}/ktsubuild" >> "$GITHUB_PATH" + + # `test all` restores, builds, and tests every test project this host can build, pinned to + # the host's runtime identifier. The pin is what makes this cheap: without it a project's + # output carries native assets for every runtime its packages ship, sixteen of them here, + # and copying that dominates the job on Windows where file writes are several times slower + # than on Linux. Measured at 115 MB against 39 MB for the smallest test project. + # + # Deliberately not `ci --no-release`: `ci` commits and pushes the metadata files when the + # build is official and on main, so with one job per platform both jobs would race to + # commit on every push to main. `test all` does no metadata, version, or release work. + # + # A project the host cannot build is skipped and named before anything is built, and a + # project that fails does not stop the ones after it, so one run reports everything broken. + # + # The five UI suites are effectively the whole cost of this job. They run in parallel, so the + # slowest of them sets the job's duration, while all nine other test projects finish in about + # thirty-four seconds combined. They are therefore run on Linux only: + # + # * measured per assembly on Windows: 17m28s, 14m32s, 10m05s, 8m20s, 1m01s against ~34s for + # everything else, in a job whose test phase took 17m45s. + # * what they exercise is a pure managed CPU rasterizer with no window, GPU or driver. Timed + # on one machine, self-contained for each runtime, it renders the same workload in 482.6ms + # on Windows against 476.0ms on Linux, so running it on one platform covers the same ground. + # * Linux is the faster host for this work, so it keeps them. + # + # Only the UI test projects are excluded. The example applications they drive stay in the + # build on both platforms, so a change that breaks one still fails here. + - name: Test + shell: bash + run: | + set -euo pipefail + if [ "${{ runner.os }}" = "Windows" ]; then + ktsubuild test all --workspace "$GITHUB_WORKSPACE" --verbose --exclude "**/*.UITests/*" + else + ktsubuild test all --workspace "$GITHUB_WORKSPACE" --verbose + fi + + - name: Upload Coverage + uses: actions/upload-artifact@v7 + if: always() + with: + name: coverage-${{ matrix.os }} + path: ./coverage/* + retention-days: 7 + if-no-files-found: warn + + release: + name: Analyze & Release + needs: [discover, test] + # `!cancelled()` is required because `test` is skipped when a repo has no test projects, and + # a skipped dependency would otherwise skip this job too. It also stops a run that + # `concurrency.cancel-in-progress` superseded from reaching `Release` and racing the newer + # run. The explicit result checks are what keep a genuine test failure from releasing anyway. + if: | + !cancelled() + && needs.discover.result == 'success' + && (needs.test.result == 'success' || needs.test.result == 'skipped') runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 30 permissions: contents: write # For creating releases and committing metadata packages: write # For publishing packages @@ -114,15 +304,48 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } "${{ runner.temp }}/ktsubuild" >> $env:GITHUB_PATH + # Every cell wrote a file named coverage.xml, so the downloads must stay in their own + # per-artifact directories. Merging them would leave one file and report the coverage of + # a single test project as though it were the whole repository's. + - name: Download Coverage + if: needs.discover.outputs.has_tests == 'true' + uses: actions/download-artifact@v7 + with: + pattern: coverage-* + path: coverage + + # SonarCloud's "previous version" new-code period needs recorded version boundaries to + # anchor to. Without /v: the scanner reports the version as "not provided", so the period + # has nothing to anchor against and widens to the whole history, which makes the new-code + # coverage condition measure the entire codebase instead of what this change touched. + # `version bump` prints the computed version as its only bare semver line. + - name: Resolve Version for Analysis + id: analysis_version + shell: pwsh + run: | + $output = & ktsubuild version bump --workspace "${{ github.workspace }}" 2>&1 + if ($LASTEXITCODE -ne 0) { $output; exit $LASTEXITCODE } + $matches = @($output | Where-Object { $_ -match '^\d+\.\d+\.\d+' }) + if ($matches.Count -ne 1) { + $output + Write-Error "Expected exactly one bare version line from 'version bump', got $($matches.Count)." + exit 1 + } + "version=$($matches[0].Trim())" >> $env:GITHUB_OUTPUT + - name: Begin SonarQube if: ${{ env.SONAR_TOKEN != '' }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: pwsh run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.projectBaseDir="${{ github.workspace }}" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs" /d:sonar.cs.vstest.reportsPaths="coverage/TestResults/**/*.trx" /d:sonar.exclusions="**/NativeExports.cs" + .\.sonar\scanner\dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" /o:"${{ github.repository_owner }}" /v:"${{ steps.analysis_version.outputs.version }}" /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.projectBaseDir="${{ github.workspace }}" /d:sonar.qualitygate.wait=true /d:sonar.cs.vscoveragexml.reportsPaths="coverage/**/coverage.xml" /d:sonar.coverage.exclusions="**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs" /d:sonar.cs.vstest.reportsPaths="coverage/**/*.trx" /d:sonar.exclusions="**/NativeExports.cs" - - name: Run KtsuBuild CI Pipeline + # `ci` rather than restore and build directly, because it is the only place that updates + # and commits the metadata files, updates the repository topics, applies the version gate + # behind `[skip ci]`, and writes the step outputs the security job reads. + # The tests already ran in the matrix, and the release waits for the quality gate below. + - name: Run KtsuBuild Pipeline id: pipeline shell: pwsh env: @@ -131,11 +354,9 @@ jobs: KTSU_PACKAGE_KEY: ${{ secrets.KTSU_PACKAGE_KEY }} EXPECTED_OWNER: ktsu-dev run: | - # Run the CI pipeline $versionBump = "${{ github.event.inputs.version-bump }}" - # Build arguments array - only add --version-bump if explicitly set (for backward compatibility during bootstrap) - $args = @("ci", "--workspace", "${{ github.workspace }}", "--verbose") + $args = @("ci", "--workspace", "${{ github.workspace }}", "--no-test", "--no-release", "--verbose") if (![string]::IsNullOrEmpty($versionBump) -and $versionBump -ne "auto") { $args += @("--version-bump", $versionBump) } @@ -151,63 +372,35 @@ jobs: run: | .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" + # Gated by the step above. `sonar.qualitygate.wait=true` makes a failed gate fail that + # step, and a step whose `if:` names no status function is implicitly gated on success, so + # a release cannot proceed past a gate the project did not pass. + - name: Release + if: steps.pipeline.outputs.should_release == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + NUGET_API_KEY: ${{ secrets.NUGET_KEY }} + KTSU_PACKAGE_KEY: ${{ secrets.KTSU_PACKAGE_KEY }} + EXPECTED_OWNER: ktsu-dev + run: | + ktsubuild release --workspace "${{ github.workspace }}" --verbose + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Upload Coverage Report uses: actions/upload-artifact@v7 if: always() with: - name: coverage-report + name: analysis-coverage-report path: | ./coverage/* retention-days: 7 if-no-files-found: ignore - winget: - name: Update Winget Manifests - needs: build - if: needs.build.outputs.should_release == 'true' - runs-on: windows-latest - timeout-minutes: 10 - permissions: - contents: write - - steps: - - name: Checkout Release Commit - uses: actions/checkout@v7 - with: - ref: ${{ needs.build.outputs.release_hash }} - fetch-depth: 0 # Full history for better auto-detection - - - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} - uses: actions/setup-dotnet@v6 - with: - dotnet-version: ${{ env.DOTNET_VERSION }}.x - - # PATH is not shared between jobs, so this job installs the tool for itself. - - name: Install KtsuBuild - shell: pwsh - run: | - dotnet tool install ktsu.KtsuBuild.Tool --tool-path "${{ runner.temp }}/ktsubuild" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - "${{ runner.temp }}/ktsubuild" >> $env:GITHUB_PATH - - - name: Update Winget Manifests - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - ktsubuild winget generate --version "${{ needs.build.outputs.version }}" --workspace "${{ github.workspace }}" --verbose - - - name: Upload Updated Manifests - uses: actions/upload-artifact@v7 - with: - name: winget-manifests-${{ needs.build.outputs.version }} - path: winget/*.yaml - retention-days: 30 - security: name: Security Scanning - needs: build - if: needs.build.outputs.should_release == 'true' + needs: release + if: needs.release.outputs.should_release == 'true' runs-on: windows-latest timeout-minutes: 10 permissions: @@ -218,6 +411,6 @@ jobs: - name: Checkout Release Commit uses: actions/checkout@v7 with: - ref: ${{ needs.build.outputs.release_hash }} + ref: ${{ needs.release.outputs.release_hash }} - name: Detect Dependencies uses: advanced-security/component-detection-dependency-submission-action@31f25a8de68ae5ce2ca274bc28546a78683c15ce # v0.1.4