Skip to content

.NET Workflow

.NET Workflow #475

Workflow file for this run

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