Dunderly HR System: CI/CD and Release Management
Dunderly HR pipeline: GitHub Actions with OIDC, build once and promote, EF Core migration bundles, smoke-tested slot swaps, and no releases in payroll week.
The 2023 case study ended at “Write Architecture Document” and “Support the Team”; it never said how code reaches Azure. For a team of four, the pipeline is the team’s most productive member: it builds, tests, scans, deploys and rolls back without needing a meeting. The one thing it isn’t allowed to do on its own is ship during payroll week.
Build automation
GitHub Actions, two workflows: pr.yml on pull requests and release.yml on pushes to main. The whole path, from PR to production. Tick the box to see failing smoke tests stop a release:
- Open a pull request: Every change, whether code, Bicep or a migration, starts as a short-lived branch and a pull request against main.
- Build and test: GitHub Actions builds the API, the Functions and the Blazor app and runs unit tests, plus integration tests against SQL Server in a container.
- Scan: CodeQL, dependency review, secret scanning and Bicep linting run on every PR. A high-severity finding fails the check.
- Preview: Static Web Apps creates a preview environment for the PR, so the HR product owner can click through a UI change before it merges.
- Review and merge: Branch protection needs one approving review and green checks. Merging to main is the only way into the release path.
- Build once: Main builds the release artifacts once (API and Functions zips, the Blazor bundle, an EF Core migration bundle), all stamped with one version.
- Deploy to dev: The workflow signs in to Azure with OIDC, so there's no stored secret. Bicep applies first, then the artifacts deploy.
- Promote to test: The same artifacts go to test, where Playwright end-to-end tests run and HR does acceptance testing on release candidates.
- Production approval: The production environment needs a named reviewer. Payroll week is a change freeze unless the fix is for payroll itself.
- Migrate, then deploy to the slot: The migration bundle applies backward-compatible schema changes, then the new build goes to the staging slot while production keeps serving.
- Smoke test the slot: Health, sign-in, an employee read and a document download run against the warmed-up staging slot.
- Swap (pass): The slot swap is the release. Rolling back is swapping again, within minutes, because the previous build is still sitting in the slot.
- Don't swap (fail): Production never saw the bad build. The team fixes forward on main, and the expand-only migration is harmless to the version still running.
The build is plain dotnet CLI with locked restores, so a CI build and a local build are the same build:
1
2
3
4
5
6
7
dotnet restore --locked-mode
dotnet build -c Release --no-restore
dotnet test -c Release --no-build --collect:"XPlat Code Coverage"
dotnet publish src/Dunderly.Hr.Api -c Release -o artifacts/api
dotnet publish src/Dunderly.Hr.Web -c Release -o artifacts/web
dotnet publish src/Dunderly.Hr.Functions -c Release -o artifacts/func
dotnet ef migrations bundle --project src/Dunderly.Hr.Api --self-contained -r linux-x64 -o artifacts/efbundle
More on .NET builds in CI in DotNet Build and on Actions basics in GitHub Actions - Introduction.
Automated test execution
| Stage | Tests | Blocks |
|---|---|---|
| PR | Unit, integration (Testcontainers SQL + Azurite), authorization matrix, architecture tests, payroll golden files | Merge |
| Deploy to test | Playwright E2E journeys with axe, ZAP baseline | Promotion to prod |
| Nightly on test | Full E2E on Chromium, Firefox, WebKit | Nothing (reports) |
| Prod staging slot | Smoke tests | Swap |
Test results publish as a check summary; coverage goes to the PR as a comment. A flaky test is quarantined with an issue, never retried silently into green.
Static analysis and security scanning
- CodeQL (C#) on every PR and weekly on
main. - Dependency review action blocks PRs adding packages with known high/critical vulnerabilities or disallowed licenses.
- Secret scanning with push protection.
- .NET analyzers as errors (
TreatWarningsAsErrors),dotnet format --verify-no-changes. - Bicep:
az bicep lintand awhat-ifagainst the target environment, posted to the PR. - OpenAPI diff flags breaking API changes for an explicit decision.
Artifact and container management
No containers in production: App Service and Functions run zip deployments, and Static Web Apps takes the built wwwroot. The release workflow builds once and uploads the artifacts (api.zip, func.zip, web/, efbundle) to the workflow run, stamped with the version (1.4.0+<short sha>). Every environment deploys those exact files. Rebuilding per environment would mean prod runs something test never saw.
Artifacts are kept for 90 days in GitHub; tagged releases also attach them to the GitHub Release, so any version can be redeployed.
Deployment automation
One reusable job deploys any environment, signed in with OIDC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
deploy:
environment: ${{ inputs.environment }} # dev | test | prod (prod has required reviewers)
permissions: { id-token: write, contents: read }
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az deployment sub create -l westeurope -f infra/main.bicep -p infra/env/${{ inputs.environment }}.bicepparam
- run: ./efbundle --connection "${{ steps.conn.outputs.sql }}" # Entra auth, no password
- uses: azure/webapps-deploy@v3
with:
app-name: app-dunderly-hr-api-${{ inputs.environment }}
slot-name: ${{ inputs.environment == 'prod' && 'staging' || 'production' }}
package: artifacts/api.zip
For prod the sequence is: Bicep → migrations → API to the staging slot → smoke tests → swap → Functions → Static Web Apps. The web app goes last so the UI never calls an API older than itself. PRs get a Static Web Apps preview environment automatically, which is where HR reviews UI changes.
Database deployment strategy
Migrations run before the new code, and are expand-only (see migrations). That ordering is what makes the slot swap safe: during the swap, both the old and the new API version run against a schema that suits both.
A destructive change (dropping a column) takes two releases: release N stops using it, release N+1 drops it. The migration bundle runs as the pipeline’s identity, which is the only principal with DDL rights in prod; the API’s identity has DML rights only. One practical catch: SQL has public network access disabled, so a GitHub-hosted runner can’t reach it. The bundle runs either on a small self-hosted runner in the VNet or as an Azure deployment script attached to the VNet; I’d pick the deployment script, because there’s no runner to patch.
Before any migration in prod, the pipeline records the current time. Point-in-time restore to that moment is the escape hatch if a data migration goes wrong, which is rare when migrations only add.
Approval gates
| Gate | Who | Where |
|---|---|---|
| PR merge | One reviewer; two for payroll paths | Branch protection + CODEOWNERS |
| Deploy to test | Automatic | — |
| Deploy to prod | A named reviewer (tech lead or architect) | GitHub environment prod |
| Payroll-impacting release | Also the payroll operator | PR label risk:payroll + checklist |
| Payroll week | Blocked unless the fix is for payroll | Release calendar check |
The prod environment also has a wait timer of zero and a deployment branch rule: only main can deploy.
Versioning and release notes
Semantic versioning for the system as a whole (MAJOR.MINOR.PATCH). The API’s URL version (/api/v1) only changes for breaking API changes, which, with one client in the same repo, should be never.
Release notes are generated from PR titles (conventional commit prefixes: feat:, fix:, chore:) into the GitHub Release, then a human writes a two-line summary for HR in plain language: “You can now withdraw a salary request before HR decides.” That second part is what users actually read.
Rollback and roll-forward procedures
| Situation | Action | Time |
|---|---|---|
| Smoke tests fail in the slot | Don’t swap; fix forward | 0 user impact |
| Problem found after swap | Swap back (old build is in the slot) | Minutes |
| Bad Functions deployment | Redeploy the previous func.zip from the release | Minutes |
| Bad web deployment | Redeploy the previous web artifact | Minutes |
| Bad data change | Point-in-time restore to a new database, repair data | Hours |
Because migrations only add, swapping back never needs a schema rollback. Default is roll forward for anything that isn’t actively hurting users; swap back when it is.
1
az webapp deployment slot swap -g rg-dunderly-hr-prod -n app-dunderly-hr-api-prod --slot staging --target-slot production
Release calendar
Small releases, often, outside payroll week:
- Normal cadence: weekly, Tuesday to Thursday mornings, when the team is around to watch.
- Freeze: from the payroll cutoff to payday, every month (assumption A6). Payroll fixes only.
- No Friday afternoon releases. Not a superstition: nobody’s watching at the weekend.
- Year end: freeze around leave carry-over and the first payroll of the year.
The calendar is a shared Outlook calendar plus a check in the workflow that reads the payroll cutoff dates from a config file and fails a prod deploy in the freeze window unless the PR has the payroll-fix label.
Checklist
- Create
pr.ymlandrelease.ymlwith the reusable deploy job. - Configure OIDC federated credentials and GitHub environments with reviewers.
- Add the smoke test suite and wire it to the swap decision.
- Grant DDL to the pipeline identity only; DML to the API identity.
- Add the payroll-freeze check with this year’s cutoff dates.
- Rehearse a swap-back in test.
Write the plain-language release note template for HR.
