GitHub Actions umstellen
Schnellere .NET-CI-Pipelines mit Test Sharding, Teil 2
Der vorangegangene Teil 1 der Serie „Schnellere .NET-CI-Pipelines mit Test Sharding“ hat die deterministische Testaufteilung erklärt und an acht xUnit-Testfällen gezeigt, wie sich die Auswahl eines Shards lokal anzeigen lässt. Dieser Beitrag zeigt, wie GitHub Actions die Teilmengen in getrennten Jobs ausführt. Dabei erhält jeder Job denselben Quellstand und dieselbe Gesamtzahl an Shards, aber eine andere Shard-Nummer. Erst diese Kombination ermöglicht eine vollständige Aufteilung ohne Überschneidungen.
In der Matrix
Eine Matrix erzeugt mehrere Jobs aus derselben Definition. Für Sharding unterscheiden sie sich zunächst nur durch eine Zahl; alles andere muss übereinstimmen: neben dem Quellstand auch die Paketversionen und die Testkonfiguration. Teilt ein Job die Suite durch vier und ein anderer durch drei, entstehen Lücken oder Überschneidungen. Auch Tests, die je nach Betriebssystem bedingt laufen, verhindern eine gemeinsame verlässliche Aufteilung, weil die Jobs dann unterschiedliche Testlisten sehen.
Basis ist das MTP-Testprojekt aus dem vorangegangenen Teil 1 mit Meziantou.ShardedTest 1.1.0. Im Repository liegen die Projektdatei, ArithmeticTests.cs, global.json mit der MTP-Auswahl und .config/dotnet-tools.json. Jeder Matrixjob erhält eine eigene Arbeitsumgebung und baut das Testprojekt selbst. Dieser einfache Aufbau verursacht zwar wiederholte Arbeit, benötigt dafür aber noch keinen Transfer von Build-Ergebnissen zwischen Jobs.
Die Matrix vollständig definieren
Die folgende Datei liegt unter .github/workflows/sharded-tests.yml. matrix.shard nimmt die Werte 1 bis 4 an, und GitHub erzeugt für jeden Wert einen Job. strategy.job-total enthält bei dieser eindimensionalen Matrix die Zahl 4. env übergibt beide Werte als SHARD und SHARDS an die Shell. Der erste Aufruf erhält somit --shard-index 1 --total-shards 4, der zweite Index 2 bei unverändertem Nenner. Die Nummerierung ab eins ist eine Vorgabe von ShardedTest.
Die Datei sharded-tests.yml ist folgendermaßen aufgebaut:
name: Sharded tests
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 4
matrix:
shard: [1, 2, 3, 4]
env:
TEST_PROJECT: tests/ShardDemo.Tests/ShardDemo.Tests.csproj
SHARD: ${{ matrix.shard }}
SHARDS: ${{ strategy.job-total }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
- run: dotnet tool restore
- run: dotnet restore "$TEST_PROJECT"
- run: dotnet build "$TEST_PROJECT" -c Release --no-restore
- name: Run shard
run: |
dotnet tool run sharded-test \
--shard-index "$SHARD" --total-shards "$SHARDS" \
--project "$TEST_PROJECT" \
--configuration Release --no-build \
--results-directory "$GITHUB_WORKSPACE/results/$SHARD" \
--report-trx \
--report-trx-filename 'tests_{pid}_{time}.trx'
- name: Keep test reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.shard }}
path: results/${{ matrix.shard }}/*.trx
if-no-files-found: error
retention-days: 7
checkout lädt den Quellstand, setup-dotnet installiert das SDK. dotnet tool restore stellt die Werkzeuge aus dem Manifest bereit; dotnet restore lädt die Projektabhängigkeiten. Danach baut dotnet build das Projekt ohne erneuten Restore. Der Testschritt verwendet dieses Release-Kompilat. Der absolute Ergebnispfad führt in den Arbeitsordner des Jobs; der Upload sammelt ausschließlich dessen TRX-Dateien ein. Das Zeitlimit beendet einen hängen gebliebenen Job nach spätestens 20 Minuten.
Die gezeigten Action-Hauptversionen entsprechen dem Recherchestand vom 17. September 2026. Für reproduzierbare Abläufe fixiert das Team geprüfte Commit-SHAs und SDK-Patches; 10.0.x lässt SDK-Updates zu. Eigene Runner benötigen eine zu den Actions passende Runner-Version.
max-parallel: 4 erlaubt bis zu vier gleichzeitige Matrixjobs, reserviert aber keine Kapazität. Verfügt das Konto oder der Runner-Pool nur über zwei freie Plätze, laufen die Jobs in mehreren Wellen. Für eine erste Messung ist deshalb die Warteschlange genauso relevant wie die eigentliche Testdauer.
Sobald die Matrix zusätzliche Dimensionen wie Betriebssystem oder Ziel-Framework enthält, ist strategy.job-total nicht mehr die Shard-Anzahl pro Umgebung. Dann erhält jede Umgebung eine eigene vollständige Shard-Gruppe mit explizit gesetztem Nenner, also --total-shards. Vier Shards auf zwei Betriebssystemen bedeuten acht Jobs, aber weiterhin vier Shards je Testsuite.
Fehler sichtbar lassen
Mit fail-fast: false lässt GitHub die übrigen Matrixjobs nach einem Testfehler weiterlaufen. Ohne diese Einstellung könnte ein früher Fehler in Shard 1 die anderen Jobs abbrechen, bevor deren Befunde vorliegen. Das Weiterlaufen liefert mehr Diagnosematerial, kostet aber zusätzliche Runner-Zeit. Der fehlgeschlagene Job bleibt fehlgeschlagen. continue-on-error hätte eine andere Wirkung: Diese Option erlaubt einen Fehler, ohne deshalb den gesamten Workflow scheitern zu lassen. Sie gehört daher nicht an verpflichtende Testjobs.
Innerhalb eines Shards gilt eine weitere Grenze: Bei vielen Testnamen kann die Filterzeichenkette zu lang werden. ShardedTest teilt die Auswahl dann in Filterbatches, also Gruppen für mehrere nacheinander gestartete dotnet test-Aufrufe. Nach dem ersten fehlgeschlagenen Aufruf beendet Version 1.1.0 den Shard. fail-fast: false steuert nur die Matrix und ändert daran nichts. Geplante Auswahl und tatsächlich abgeschlossene Tests können daher voneinander abweichen.
Der Upload-Schritt läuft auch nach einem fehlgeschlagenen Testschritt, sofern niemand den Workflow abgebrochen hat. Ein fehlender Report lässt diesen Schritt scheitern. Für die Demo ist das sinnvoll, weil jeder Shard Tests enthält. Bei bewusst erlaubten leeren Shards braucht das Team stattdessen eine ausdrückliche Regel für erwartete Testzahlen. Ein Prozessabbruch kann zudem unvollständige Reports hinterlassen.
Warum ein Dateiname pro Shard nicht genügt
Die Shard-Nummer trennt die Ergebnisverzeichnisse. Zusätzlich enthalten die TRX-Dateinamen Prozess-ID und Zeitstempel. Das ist bei den beschriebenen Filterbatches wichtig: Jeder dotnet test-Aufruf schreibt seinen eigenen Bericht. Ein fester Name wie results.trx könnte dabei frühere Ergebnisse desselben Shards überschreiben.
Die TRX-Erweiterung bietet die Platzhalter {pid} und {time} seit MTP 2.3. Das in Teil 1 dieser Serie fixierte Paket 2.3.3 passt dazu. Die einfachen Anführungszeichen geben die Vorlage unverändert an die Testanwendung weiter; der Reporter ersetzt die Platzhalter. Ohne installiertes TRX-Paket kennt die Testanwendung --report-trx nicht.
GitHub bewahrt die Dateien sieben Tage als herunterladbare Artefakte auf. Der Upload erzeugt noch keine gemeinsame Testübersicht und erhält den übergeordneten Shard-Ordner nicht. Für die Auswertung aus Teil 4 entpacken wir daher test-results-1 nach results/1, test-results-2 nach results/2 und entsprechend weiter. So bleiben sämtliche Reports ihrem Shard zugeordnet.
Build und Discovery auseinanderhalten
ShardedTest entdeckt vor jeder Auswahl zunächst die Tests. Mit dem vorgelagerten Build und --no-build verhindern wir, dass diese Discovery und mögliche Filterbatches wiederholt den Build anstoßen. Die Kosten für Prozessstart und Discovery bleiben dennoch pro Shard bestehen. Ein NuGet-Cache kann zwar Downloads verkürzen, ersetzt aber keine gebauten Testdateien.
Bei langen Builds kann ein gemeinsamer Buildjob seine Ergebnisse an die Shards verteilen. Das lohnt sich erst, wenn die eingesparte Kompilierung den Upload, die Downloads und die zusätzliche Abhängigkeit übersteigt. Das Artefakt muss auch Laufzeitdateien, Abhängigkeiten und benötigte Testdaten enthalten. Ein Verzeichnis mit ausschließlich der Test-DLL genügt nicht. Nutzt die Ausführung weiterhin den Projektpfad, sind außerdem die erforderlichen Projekt- und MSBuild-Zustände zu beachten.
Zwischenfazit: Vier Jobs mit nachvollziehbarem Ergebnis
Die Matrix verteilt jetzt dieselbe Suite auf vier Jobs, lässt Fehler sichtbar und bewahrt deren Reports auf. Ob das schneller ist, hängt auch von Buildkosten und freien Runnern ab. Weil zudem SDK-Version, Lockdateien und Cachezustand die Laufzeit beeinflussen, gehören sie ins Messprotokoll. Im nächsten Teil dieser Serie übertragen wir den Aufbau nach GitLab und ergänzen die Ergebnisdarstellung im Merge Request.