CI/CD Pipelines
Key Points
- GitHub Actions is the modern default for .NET (best ecosystem, free for public, generous private quotas). Azure DevOps Pipelines strong in enterprises.
- Build in Release; restore + build + test + publish artifacts.
- Deploy with OIDC federated identity to Azure — no secrets in pipeline.
- Multi-stage: build → test → deploy-dev → deploy-staging → deploy-prod (gated).
- Slot swaps for App Service, revisions for Container Apps, rolling/blue-green for K8s.
Concepts (deep dive)
What CI and CD actually mean
Both terms get used interchangeably and shouldn't. They're two stages of the same flow:
- Continuous Integration (CI) = every commit triggers a build, test, and verify run. The output is a high-confidence artifact and a pass/fail signal. CI exists to catch breakage at the moment a developer can still remember what they did, instead of at release time when the cause has scrolled off the screen.
- Continuous Delivery / Deployment (CD) = that artifact then flows through environments (dev → staging → prod) automatically. "Delivery" means it's ready to ship at any time; "Deployment" means it actually ships without a human gate.
commit ──► [ CI ] [ CD ]
───────── ─────────
checkout download artifact
restore + build azure/login (OIDC)
test (unit + integ) deploy → dev
publish artifact smoke tests
↓ deploy → staging
artifact in registry approval gate
deploy → prod
post-deploy verification
The pipeline is declarative YAML in the repo, version-controlled alongside the code, so the build process evolves with the app. That's the modern shape; the legacy world of "click around a GUI build server" is what GitHub Actions / Azure DevOps replaced.
Why federated identity changes everything
The pre-OIDC story for "CI deploys to Azure" required storing a service-principal client secret as a GitHub/ADO secret. That secret then had to be rotated, kept out of logs, scoped, and audited. Federated identity credentials (FIC) invert the flow: the CI runtime (GitHub Actions, Azure DevOps, GitLab) issues a short-lived OIDC token signed by its identity provider; you pre-register that issuer + subject pattern as trusted on the Azure managed identity, and Azure exchanges the OIDC token for an ARM token. No secret ever crosses the wire or is stored anywhere — and the trust is scoped to a specific repo + branch + workflow.
For new pipelines in 2026: always FIC. Client-secret SP login is the legacy path.
GitHub Actions for .NET
name: ci-cd
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # for OIDC
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '9.0.x' }
- run: dotnet restore
- run: dotnet build -c Release --no-restore
- run: dotnet test -c Release --no-build --logger trx --collect:"XPlat Code Coverage"
- uses: actions/upload-artifact@v4
with: { name: testresults, path: '**/TestResults' }
- run: dotnet publish src/MyApp.Api -c Release -o ./publish
- uses: actions/upload-artifact@v4
with: { name: app, path: ./publish }
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production # gated approval
steps:
- uses: actions/download-artifact@v4
with: { name: app, path: ./publish }
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az webapp deploy --name myapp --resource-group rg --src-path ./publish --type zip
Federated identity (OIDC) — secret-free auth
az identity federated-credential create \
-g rg -i mi --name github-deploy \
--issuer https://token.actions.githubusercontent.com \
--subject "repo:org/repo:ref:refs/heads/main" \
--audience api://AzureADTokenExchange
GitHub issues OIDC token to Azure → Azure mints access token → app deploys. No secret stored anywhere.
Azure DevOps Pipelines
trigger: [main]
pool: { vmImage: 'ubuntu-latest' }
stages:
- stage: Build
jobs:
- job: Build
steps:
- task: UseDotNet@2
inputs: { version: '9.0.x' }
- script: dotnet build -c Release
- script: dotnet test -c Release --collect "Code Coverage"
- task: PublishBuildArtifacts@1
- stage: Deploy
dependsOn: Build
condition: succeeded()
jobs:
- deployment: ToProd
environment: production
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'sc-prod'
appName: 'myapp'
package: '$(Pipeline.Workspace)/drop/**/*.zip'
Multi-stage strategy
PR → build + test
main merge → build + deploy-dev → smoke tests → deploy-staging → manual approval → deploy-prod
Each environment gated on quality + manual approval where appropriate.
Slot swap (App Service)
- run: |
az webapp deploy --name myapp --slot staging --src-path ...
az webapp deployment slot swap --name myapp --slot staging --target-slot production
Deploy to staging; warm up; swap. Rollback by swapping back.
Container Apps revisions
- run: |
az containerapp update --name myapp --resource-group rg \
--image myacr.azurecr.io/myapp:${{ github.sha }} \
--revision-suffix ${{ github.sha }}
# 100% traffic to new revision after warmup
Or canary: split traffic 80/20.
K8s deploy patterns
- Rolling: replace pods one by one (default). Fast; brief mixed versions.
- Blue-green: two full environments; switch traffic. Heavy resource use; clean.
- Canary: small % to new; monitor; ramp.
- run: kubectl set image deployment/myapp api=myacr/myapp:${{ github.sha }}
- run: kubectl rollout status deployment/myapp
Caching
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
Speeds NuGet restore significantly.
Test results + coverage
- name: Publish test results
uses: dorny/test-reporter@v1
with:
name: tests
path: '**/*.trx'
reporter: dotnet-trx
- uses: codecov/codecov-action@v5
Secrets
In CI: prefer federated credentials over secrets. If you must store secrets: - GitHub: Repository / environment secrets. - ADO: Variable groups linked to Key Vault.
Environments + approvals
GitHub: settings → environments → required reviewers.
ADO: similar concept via approval gates.
Code quality checks
- run: dotnet format --verify-no-changes
- run: dotnet build /p:EnforceCodeStyleInBuild=true
- uses: github/super-linter@v6
- run: dotnet tool run dotnet-stryker # mutation testing (optional, slow)
Security scans
- name: Vulnerability scan
run: dotnet list package --vulnerable --include-transitive
- uses: aquasecurity/trivy-action@master
with: { image-ref: myacr/myapp:${{ github.sha }} }
- uses: github/codeql-action/analyze@v3 # SAST
Build matrix
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
dotnet: ['8.0.x', '9.0.x']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-dotnet@v4
with: { dotnet-version: ${{ matrix.dotnet }} }
Self-hosted runners
For: - Faster builds (more CPU/RAM). - Private network access. - Cost optimization at scale.
Trade-off: maintenance.
Caching Docker layers
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myacr/myapp:${{ github.sha }}
cache-from: type=registry,ref=myacr/myapp:cache
cache-to: type=registry,ref=myacr/myapp:cache,mode=max
Trunk-based development
Modern .NET CI/CD often uses trunk-based: - Short-lived feature branches. - PR with checks; squash merge to main. - main = always deployable. - Feature flags for incomplete features.
Common pipeline mistakes
- Skipping tests under time pressure.
- No artifact: rebuild on each stage.
- Secrets in pipeline instead of FIC.
- No env separation: deploying directly to prod.
- No rollback plan: panic when bad deploy lands.
Code: correct vs wrong
❌ Wrong: secrets in CI
✅ Correct: OIDC
❌ Wrong: deploy without artifact
✅ Correct: artifact upload + download
Design patterns for this topic
Pattern 1 — "OIDC federated credentials"
- Intent: secret-free CI.
Pattern 2 — "Multi-stage with gated approvals"
- Intent: safety + automation.
Pattern 3 — "Slot swap / revision for zero-downtime"
- Intent: safe deploys.
Pattern 4 — "Cache NuGet + Docker layers"
- Intent: fast CI.
Pattern 5 — "Trunk-based + feature flags"
- Intent: continuous deploy.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| GitHub Actions | Best ecosystem; OIDC | YAML can sprawl |
| Azure DevOps | Enterprise features | Older feel |
| Self-hosted | Speed | Maintenance |
When to use / when to avoid
- Use GitHub Actions for new projects.
- Use OIDC always.
- Use environment gates for prod.
- Avoid secrets in pipeline if FIC works.
Interview Q&A
Q1. GitHub Actions vs Azure DevOps? GHA: best ecosystem, OIDC, simpler. ADO: enterprise features, deep MS integrations.
Q2. OIDC federated credentials? GitHub-issued token trusted by Azure → access token. No secret stored.
Q3. Multi-stage pipelines? build → test → deploy-dev → deploy-staging (gated) → deploy-prod (gated).
Q4. Slot swap? App Service: deploy to staging; warm; swap with prod. Zero-downtime; instant rollback.
Q5. Container Apps deploy strategy? Revisions; traffic split; canary.
Q6. K8s rolling vs blue-green? Rolling: replace pods. Blue-green: two envs; switch traffic.
Q7. Caching strategies? NuGet packages cache; Docker layer cache; restore output cache.
Q8. Coverage gate? dotnet test --collect "XPlat Code Coverage" + threshold check.
Q9. Security scans in CI? dotnet list package --vulnerable; Trivy for images; CodeQL SAST; secret scanning.
Q10. Approvals? GHA environments with required reviewers. ADO approval gates.
Q11. Trunk-based development? Short branches; PR; merge to main; feature flags for incomplete. Always deployable.
Q12. Self-hosted runners — when? Faster builds; private networks; cost at scale. Trade: maintenance.
Gotchas / common mistakes
- ⚠️ Secrets in pipeline — use FIC.
- ⚠️ No artifact upload — rebuild per stage.
- ⚠️ Direct deploy to prod — no staging gate.
- ⚠️ No rollback — panic on bad deploy.
- ⚠️ Skipping tests for "speed".