GitHub Actions · Lesson 5 of 5
Complete .NET CI Pipeline — Build, Test, Publish
Complete .NET CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
DOTNET_VERSION: '8.x'
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_NOLOGO: true
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for SonarCloud / GitVersion
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# Cache NuGet packages — large speed improvement on subsequent runs
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore Clinical.sln
- name: Build
run: dotnet build Clinical.sln --no-restore --configuration Release
- name: Run unit tests
run: |
dotnet test Clinical.sln \
--no-build \
--configuration Release \
--filter "Category!=Integration" \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage \
--logger "trx;LogFileName=test-results.trx"
- name: Upload test results
uses: actions/upload-artifact@v4
if: always() # upload even if tests fail
with:
name: test-results
path: ./coverage/**/*.trx
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/**/coverage.cobertura.xml
fail_ci_if_error: trueSeparate Integration Test Job
integration-tests:
runs-on: ubuntu-latest
needs: build-and-test # run after unit tests pass
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: YourStrong!Passw0rd
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong!Passw0rd -Q 'SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore Clinical.sln
- name: Build
run: dotnet build Clinical.sln --no-restore --configuration Release
- name: Run integration tests
run: |
dotnet test Clinical.sln \
--no-build \
--configuration Release \
--filter "Category=Integration" \
--logger "trx;LogFileName=integration-results.trx"
env:
ConnectionStrings__Clinical: "Server=localhost,1433;Database=ClinicalTest;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True"Code Quality Checks
code-quality:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install dotnet-format
run: dotnet tool install --global dotnet-format
- name: Check formatting
run: dotnet format --verify-no-changes --no-restore Clinical.sln
# Fails CI if code is not formatted — enforce EditorConfig rules
- name: Check for outdated packages
run: |
dotnet list Clinical.sln package --outdated --format json > outdated.json
# Optional: fail if critical packages are outdated
- name: Run architecture tests
run: |
dotnet test tests/ArchitectureTests/ArchitectureTests.csproj \
--no-restore \
--configuration Release
# Runs ArchUnitNET tests that enforce module boundariesDocker Image Build Job
build-docker:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: src/Host/SystemForge.Api/Dockerfile
push: ${{ github.ref == 'refs/heads/main' }} # push only from main
tags: |
ghcr.io/${{ github.repository }}/clinical-api:latest
ghcr.io/${{ github.repository }}/clinical-api:${{ github.sha }}
cache-from: type=gha # GitHub Actions cache for Docker layers
cache-to: type=gha,mode=maxOptimised Dockerfile for .NET
# Multi-stage build — small final image, fast layer caching
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy only project files first — layer cache hit if no .csproj changes
COPY ["src/Host/SystemForge.Api/SystemForge.Api.csproj",
"src/Host/SystemForge.Api/"]
COPY ["src/Modules/Prescriptions/Prescriptions.Application/Prescriptions.Application.csproj",
"src/Modules/Prescriptions/Prescriptions.Application/"]
# ... other projects
RUN dotnet restore "src/Host/SystemForge.Api/SystemForge.Api.csproj"
# Now copy source and build
COPY . .
RUN dotnet publish "src/Host/SystemForge.Api/SystemForge.Api.csproj" \
--configuration Release \
--no-restore \
--output /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
# Non-root user for security
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup
USER appuser
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "SystemForge.Api.dll"]Production issue I've seen: A team's CI pipeline ran
dotnet restoreanddotnet buildwithout caching NuGet packages. Each CI run took 8-12 minutes just for restore and build. Developers stopped pushing frequently because "CI takes forever" — they accumulated large changes between pushes and PRs became hard to review. Addingactions/cache@v4for the~/.nuget/packagesdirectory reduced restore time from 6 minutes to under 30 seconds (cache hit). Build time dropped from 12 minutes to 3 minutes. Developers started pushing smaller, more frequent commits. The total time investment: 15 minutes to add the cache step.
Key Takeaway
Structure the CI pipeline as separate jobs: build+unit-tests (fast, blocks everything), integration-tests (slower, runs in parallel or after), code-quality (format check, architecture tests), docker-build. Cache NuGet packages with
actions/cache@v4— this is the single highest-impact performance improvement for .NET CI. Run integration tests against a real database using GitHub Actions services. Enforce formatting withdotnet format --verify-no-changesto prevent formatting debates in code review. Build and push Docker images only from the main branch.