Skip to content

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

environment: production

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

env:
  AZURE_PASSWORD: ${{ secrets.AZURE_PWD }}
- run: az login -u user -p $AZURE_PASSWORD

✅ Correct: OIDC

- uses: azure/login@v2
  with: { client-id, tenant-id, subscription-id }   # no secret

❌ Wrong: deploy without artifact

- run: dotnet publish ...
- run: az webapp deploy ...
# Lost on stage transition

✅ Correct: artifact upload + download

build: upload-artifact
deploy: download-artifact + deploy

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".

Further reading