Every manual deployment is a small bet that you'll remember all the steps, in order, under pressure. CI/CD replaces that bet with a pipeline: every push builds, tests, and packages the same way; releases become boring. This part wires GitHub Actions for the roadmap API — a CI workflow that builds, smoke-tests, and produces the Docker image, then the deploy job that ships it to Azure from part 7.

The CI workflow
.github/workflows/ci.yml in the repository root:
name: ci
on:
push:
branches: [master]
pull_request:
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release
- name: Smoke test the API
run: |
dotnet run --project src/Roadmap.Api -c Release --no-build &
sleep 8
curl -sf http://localhost:5152/products | grep "Mechanical keyboard"
- name: Docker image
run: docker build -t roadmap-api .
Design notes:
pull_request+pushtriggers — PRs get the full check before merge; master gets it again on the real merge commit.- A smoke test beats no test. Unit tests belong here too (
dotnet test), but even this curl — boot the app, hit an endpoint, assert on the payload — catches the "builds fine, crashes on startup" class that unit tests miss. It exercises DI registration, EF model building, and seeding in one line. - The Docker build runs in CI even before you deploy from CI, because part 6 taught us image builds fail for environment reasons (
.dockerignore, restore state). Find out on the runner, not on release day.
One practical note from setting this up: pushing workflow files requires an OAuth token with the workflow scope — if your gh login predates that scope, gh auth refresh -h github.com -s workflow fixes the 403.
Adding the deploy job
Deployment extends the same file. The modern way to let GitHub talk to Azure is OpenID Connect federation — no long-lived password stored in GitHub at all; Azure trusts tokens GitHub mints per run:
deploy:
needs: build-test
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Build and push image
run: az acr build --registry roadmapacr --image roadmap-api:${{ github.sha }} .
- name: Deploy revision
run: |
az containerapp update --name roadmap-api --resource-group roadmap-rg \
--image roadmapacr.azurecr.io/roadmap-api:${{ github.sha }}
The load-bearing details:
needs: build-test+ the branchif— deploys only happen for master commits that passed CI. PRs build; they never deploy.github.shaas the image tag. Every deployment maps to exactly one commit. "What's running in prod?" becomesaz containerapp show+git show. Never deploy:latest— it's a tag that answers no questions.- The three
AZURE_*values are identifiers, not secrets (client/tenant/subscription IDs); the trust lives in the federated credential you create once on an Entra app registration, scoped to this repo and branch.
Rollback falls out for free: redeploy the previous SHA, or shift traffic to the previous Container Apps revision.
Environments and approvals
GitHub Environments add the governance layer when you need it: attach the deploy job to environment: production, and you can require a human approval click, restrict which branches may target it, and scope secrets per environment. A staging job deploying automatically + a production job requiring approval is the 80% pattern:
environment: production # approval rules configured in repo settings
Real tests in the pipeline
The smoke test guards startup; unit and integration tests guard behavior. The fuller job most teams converge on:
- name: Unit tests
run: dotnet test tests/Roadmap.UnitTests -c Release --no-build --logger trx
- name: Integration tests
run: dotnet test tests/Roadmap.IntegrationTests -c Release --no-build
env:
ConnectionStrings__Default: "Host=localhost;Database=roadmap_test;Username=postgres;Password=postgres"
with a service container supplying the database:
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: roadmap_test }
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-retries 10
Because part 1 made the domain and application layers infrastructure-free, the unit-test job needs nothing but the runner — it finishes in seconds and gates every PR. The integration job runs real EF migrations and Dapper SQL against disposable Postgres; slower, but it's the only thing that catches a renamed column before production does. Publish results with dorny/test-reporter so failures annotate the PR diff instead of hiding in logs.
When the pipeline fails: debugging CI
CI failures split into three families, each with a fast diagnostic:
- Fails in CI, passes locally. Almost always environment: a file the runner doesn't have (check
.gitignoreagainst what the build needs), case-sensitive paths (Linux runners care; macOS doesn't — ourGeeksArray.AtsvsGeeksArray.ATScasing bug shipped exactly this way), or a missing env var.git clean -xdf && dotnet buildlocally reproduces most of them. - Flaky. Timing-dependent tests, port collisions, external calls. Quarantine the test the day it first flakes — a suite people re-run on red is a suite nobody trusts, and its signal is already gone.
- Slow creep. Pipelines gain steps and lose minutes. Budget it like a feature: cache restore, split jobs to run in parallel, and alert when total time crosses your threshold (10 minutes is a good line for a service this size).
gh run view --log-failed pulls just the failing step's log to your terminal — faster than clicking through the web UI, and scriptable when you're fixing a red main branch under pressure.
Pipeline hygiene that pays compound interest
- Cache NuGet (
actions/setup-dotnetwithcache: true) — restore drops from minutes to seconds. - Fail fast, fail loud.
curl -sf(the-f) makes non-2xx responses fail the step; without it your smoke test is decorative. - Keep the workflow in the repo, reviewed like code — it is the release process, and PR review is your change-management.
- Concurrency guards (
concurrency: production-deploy) stop two merges racing each other mid-deploy.
Database migrations in the pipeline
The step everyone defers: schema changes must ship with code changes, safely. Three workable strategies, in order of increasing rigor:
- Migrate on startup (
db.Database.Migrate()behind a flag) — fine for single-replica apps; dangerous with multiple replicas racing to migrate. If you use it, gate it so only one instance runs migrations (an init job, or a startup lock table). - A migration job in the pipeline — the deploy job runs
dotnet ef database update(or applies an idempotent SQL script generated bydotnet ef migrations script --idempotent) before swapping traffic to the new revision. The script variant is auditable and doesn't need the SDK on the target. - Expand–contract for zero-downtime — additive migration first (new nullable column), deploy code that writes both, backfill, then a later contract migration removes the old shape. This is the only strategy that survives rolling deployments where old and new code briefly coexist — which, with Container Apps revisions, is every deployment.
Whichever you choose, one rule is absolute: the pipeline, not a human with a connection string, applies production schema changes. The migration script in the artifact + the SHA-tagged image means any environment can be reconstructed to a known pair of (code, schema) — which is what turns "the deploy broke" from archaeology into a diff.
The goal state is cultural, not technical: deploys stop being events. geeksarray.com ships this way — a push builds the image on the server and swaps the container; publishing a code change and publishing a blog post are comparably dull.
Next
One well-shipped service is the right place to pause and ask the uncomfortable question: should it become several? Part 9 tackles microservices — what they buy, what they cost, and why the modular monolith we've been building is the best launchpad either way.
Comments (0)
No comments yet — be the first to share your thoughts.