.NET Workflow #475
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: .NET Workflow | |
| on: | |
| push: | |
| branches: [main, develop] | |
| 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 | |
| inputs: | |
| version-bump: | |
| description: 'Version bump type' | |
| required: false | |
| default: 'auto' | |
| type: choice | |
| options: | |
| - auto | |
| - patch | |
| - minor | |
| - major | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Default permissions | |
| permissions: | |
| contents: read | |
| env: | |
| DOTNET_VERSION: "10.0" # Only needed for actions/setup-dotnet | |
| jobs: | |
| 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. | |
| # | |
| # UI test projects are excluded on Windows. What they exercise is a pure managed CPU | |
| # rasterizer with no window, GPU or driver, so one platform covers the same ground, and | |
| # Linux is both the faster host for that work and the cheaper runner. Where these suites | |
| # exist they dominate the job, running tens of minutes on Windows against seconds for | |
| # everything else. A repository with no UI test project matches nothing here and is | |
| # unaffected, which is why the exclusion is safe to carry in the shared workflow. | |
| # | |
| # Only the 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: 30 | |
| permissions: | |
| contents: write # For creating releases and committing metadata | |
| packages: write # For publishing packages | |
| outputs: | |
| version: ${{ steps.pipeline.outputs.version }} | |
| release_hash: ${{ steps.pipeline.outputs.release_hash }} | |
| should_release: ${{ steps.pipeline.outputs.should_release }} | |
| steps: | |
| - name: Set up JDK 17 | |
| uses: actions/setup-java@v5 | |
| with: | |
| java-version: 17 | |
| distribution: "zulu" # Alternative distribution options are available. | |
| - name: Checkout Repository | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 # Full history for versioning | |
| fetch-tags: true | |
| lfs: true | |
| submodules: recursive | |
| persist-credentials: true | |
| - 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 | |
| # Ensure NuGet packages directory exists for caching (prevents error when pipeline exits early) | |
| - name: Ensure NuGet cache directory exists | |
| run: New-Item -Path "$env:USERPROFILE\.nuget\packages" -ItemType Directory -Force | |
| shell: pwsh | |
| - name: Cache SonarQube Cloud packages | |
| if: ${{ env.SONAR_TOKEN != '' }} | |
| uses: actions/cache@v6 | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| with: | |
| path: ~\sonar\cache | |
| key: ${{ runner.os }}-sonar | |
| restore-keys: ${{ runner.os }}-sonar | |
| - name: Cache SonarQube Cloud scanner | |
| if: ${{ env.SONAR_TOKEN != '' }} | |
| id: cache-sonar-scanner | |
| uses: actions/cache@v6 | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| with: | |
| path: .\.sonar\scanner | |
| key: ${{ runner.os }}-sonar-scanner | |
| restore-keys: ${{ runner.os }}-sonar-scanner | |
| - name: Install SonarQube Cloud scanner | |
| if: ${{ env.SONAR_TOKEN != '' && steps.cache-sonar-scanner.outputs.cache-hit != 'true' }} | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| shell: pwsh | |
| run: | | |
| New-Item -Path .\.sonar\scanner -ItemType Directory | |
| dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner | |
| - 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 | |
| # Each platform's artifact holds one coverage.xml, already merged across that platform's test | |
| # projects by `test all`. The downloads must stay in their own per-artifact directories so | |
| # both survive: flattened, one platform's report would overwrite the other's and the scanner | |
| # would see a single platform's coverage as though it were the whole matrix'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 | |
| # The quality gate blocks the release only where a repository opts in, by setting the | |
| # SONAR_BLOCKING_GATE repository variable to true. It is not on by default because most of | |
| # these repositories carry security hotspots that have never been reviewed, and a gate they | |
| # have never been held to would stop every release at once rather than improve anything. The | |
| # analysis is still uploaded and the gate is still evaluated either way, so turning a | |
| # repository on is a variable away once its findings are triaged. | |
| - name: Begin SonarQube | |
| if: ${{ env.SONAR_TOKEN != '' }} | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| SONAR_BLOCKING_GATE: ${{ vars.SONAR_BLOCKING_GATE }} | |
| shell: pwsh | |
| run: | | |
| $sonarArgs = @( | |
| '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.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' | |
| ) | |
| if ($env:SONAR_BLOCKING_GATE -eq 'true') { | |
| $sonarArgs += '/d:sonar.qualitygate.wait=true' | |
| Write-Host 'Quality gate is blocking for this repository.' | |
| } else { | |
| Write-Host 'Quality gate is advisory for this repository. Set the SONAR_BLOCKING_GATE variable to true to enforce it.' | |
| } | |
| & .\.sonar\scanner\dotnet-sonarscanner @sonarArgs | |
| # `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 where the gate is blocking the release waits for | |
| # it below. | |
| - name: Run KtsuBuild Pipeline | |
| id: pipeline | |
| 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: | | |
| $versionBump = "${{ github.event.inputs.version-bump }}" | |
| $args = @("ci", "--workspace", "${{ github.workspace }}", "--no-test", "--no-release", "--verbose") | |
| if (![string]::IsNullOrEmpty($versionBump) -and $versionBump -ne "auto") { | |
| $args += @("--version-bump", $versionBump) | |
| } | |
| & ktsubuild @args | |
| if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } | |
| - name: End SonarQube | |
| if: env.SONAR_TOKEN != '' && steps.pipeline.outputs.build_skipped != 'true' | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| shell: pwsh | |
| run: | | |
| .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" | |
| # Gated by the step above, but only where the gate is blocking. With SONAR_BLOCKING_GATE | |
| # set, `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. Without it the analysis is still published and the | |
| # gate still evaluated, it just does not hold up the release. | |
| - 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: analysis-coverage-report | |
| path: | | |
| ./coverage/* | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| security: | |
| name: Security Scanning | |
| needs: release | |
| if: needs.release.outputs.should_release == 'true' | |
| runs-on: windows-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| id-token: write # For dependency submission | |
| contents: write # For dependency submission | |
| steps: | |
| - name: Checkout Release Commit | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.release.outputs.release_hash }} | |
| - name: Detect Dependencies | |
| uses: advanced-security/component-detection-dependency-submission-action@31f25a8de68ae5ce2ca274bc28546a78683c15ce # v0.1.4 |