.NET & C# Development · Lesson 178 of 229
GitHub Actions CI/CD for .NET — Full Pipeline
GitHub Actions CI/CD for .NET — Full Pipeline
A production CI/CD pipeline for .NET does more than dotnet build and dotnet test — it enforces code coverage, builds Docker images, scans for vulnerabilities, deploys to staging automatically, and requires approval for production.
Pipeline Overview
Trigger: push to main OR pull_request
Jobs (in parallel where possible):
build — restore, build, test, upload coverage
security — run CodeQL analysis, Trivy dependency scan
docker — build and push image to registry
↓
deploy-staging — deploy to staging environment (auto)
↓
deploy-prod — deploy to production (requires manual approval)Step 1: Core Build and Test
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
DOTNET_VERSION: '9.x'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for GitVersion
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: ${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run tests with coverage
run: |
dotnet test \
--no-build \
--configuration Release \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage \
--logger "trx;LogFileName=test-results.trx" \
/p:CoverletOutputFormat=cobertura
- name: Enforce coverage threshold
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:"./coverage/**/coverage.cobertura.xml" \
-targetdir:"./coverage-report" \
-reporttypes:TextSummary
COVERAGE=$(cat ./coverage-report/Summary.txt | grep "Line coverage:" | grep -oP '\d+\.\d+')
echo "Line coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below minimum 80%"
exit 1
fi
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
./coverage
./coverage-report
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: '**/*.trx'
reporter: dotnet-trx
- name: Calculate version
id: version
run: |
VERSION="${{ github.run_number }}.0.${{ github.sha }}"
SHORT="${{ github.run_number }}.0.0"
echo "version=$SHORT" >> $GITHUB_OUTPUT
echo "Building version $SHORT"Step 2: Security Scanning
security:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: csharp
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Build for CodeQL
run: dotnet build --configuration Release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# Scan NuGet dependencies for known CVEs
- name: Run dependency audit
run: |
dotnet list package --vulnerable --include-transitive 2>&1 | tee vuln-report.txt
if grep -q "has the following vulnerable packages" vuln-report.txt; then
echo "Vulnerable packages detected — failing build"
cat vuln-report.txt
exit 1
fi
- name: Upload vulnerability report
uses: actions/upload-artifact@v4
if: always()
with:
name: vulnerability-report
path: vuln-report.txtStep 3: Docker Build and Push
docker:
runs-on: ubuntu-latest
needs: [build, security]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_VERSION=${{ needs.build.outputs.version }}Step 4: Dockerfile (Optimised for CI)
# Multi-stage build — small final image, layer caching for dependencies
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy project files first — cached layer if no .csproj changes
COPY ["src/OrderService/OrderService.csproj", "src/OrderService/"]
COPY ["src/OrderService.Core/OrderService.Core.csproj", "src/OrderService.Core/"]
RUN dotnet restore "src/OrderService/OrderService.csproj"
# Copy source, build, publish
COPY . .
WORKDIR "/src/src/OrderService"
RUN dotnet build -c Release -o /app/build --no-restore
RUN dotnet publish -c Release -o /app/publish --no-restore
# Runtime image — no SDK
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Non-root user
RUN addgroup --system --gid 1001 appgroup \
&& adduser --system --uid 1001 --ingroup appgroup appuser
USER appuser
COPY --from=build --chown=appuser:appgroup /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "OrderService.dll"]Step 5: Staging Deployment
deploy-staging:
runs-on: ubuntu-latest
needs: docker
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to Azure Container Apps (Staging)
uses: azure/container-apps-deploy-action@v1
with:
appSourcePath: ${{ github.workspace }}
acrName: myregistry
containerAppName: order-service-staging
resourceGroup: rg-staging
imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
- name: Wait for deployment health
run: |
echo "Waiting for staging to become healthy..."
for i in $(seq 1 12); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health/ready)
if [ "$STATUS" = "200" ]; then
echo "Staging is healthy"
exit 0
fi
echo "Attempt $i: status $STATUS — waiting 10s"
sleep 10
done
echo "Staging did not become healthy in time"
exit 1
- name: Run smoke tests
run: |
curl -f https://staging.example.com/health/ready
curl -f https://staging.example.com/api/orders/healthStep 6: Production Deployment with Approval
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production # This environment has required reviewers configured in GitHub
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to Azure Container Apps (Production)
uses: azure/container-apps-deploy-action@v1
with:
containerAppName: order-service-prod
resourceGroup: rg-production
imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
- name: Notify deployment
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: "Production deploy of ${{ env.IMAGE_NAME }}:${{ github.sha }} — ${{ job.status }}"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}Step 7: PR-Only Quality Checks
pr-quality:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Validate branch name
run: |
BRANCH=${{ github.head_ref }}
if ! echo "$BRANCH" | grep -qE '^(feature|fix|chore|docs)/[a-z0-9-]+$'; then
echo "Branch name '$BRANCH' does not match convention: feature/|fix/|chore/|docs/ + kebab-case"
exit 1
fi
- name: Check for TODO/FIXME comments
run: |
COUNT=$(grep -rn "TODO\|FIXME\|HACK" --include="*.cs" src/ | wc -l)
echo "Found $COUNT TODO/FIXME comments"
# Warn but don't fail (team decision)
- name: Validate no secrets in code
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEADInterview Answer
"A production .NET CI/CD pipeline in GitHub Actions has five stages. Build: restore, build in Release mode, run tests with XPlat Code Coverage, fail if line coverage drops below threshold (80% is a common floor). Security: CodeQL static analysis plus dotnet list package --vulnerable --include-transitive to catch known CVE dependencies. Docker: multi-stage Dockerfile with SDK layer for build and aspnet runtime layer for the final image, pushed to GHCR with SHA-based tags for exact traceability. Staging: auto-deploy on merge to main, wait for health endpoint, run smoke tests. Production: requires a GitHub environment with required reviewers — the pipeline pauses until an approved person clicks Approve. Key optimisations: NuGet cache via actions/cache keyed on .csproj hash (restores in seconds instead of minutes), Docker layer caching via type=gha (skips unchanged layers), and parallel security scanning with the build job. All artifacts (test results, coverage, vulnerability report) are uploaded with upload-artifact for post-mortems."