GitHub Actions CI/CD
Four GitHub Actions workflows build, test and scan AppointMe on every PR. A v* tag deploys the .NET 10 container to Azure over OIDC and publishes the dotnet new template to NuGet.
- Workflows
devtest,template,codeql,secret-scan- Deploys on
- A pushed
v*tag, or a manual run - Azure login
- OIDC, no stored credentials
- Dependencies
- Dependabot, weekly, 7-day cooldown
When each workflow runs
Section titled “When each workflow runs”Every pull request and every push to main builds, tests and scans. Only a v* tag, or a manual run, deploys and publishes.
| Workflow | File | Triggers | Jobs |
|---|---|---|---|
codeql |
codeql.yml |
Push to main, PRs into main, Mondays at 05:30 UTC |
analyze (C# and JS/TS) |
devtest |
devtest.yml |
Push to main, v* tags, every PR, manual |
test, then deploy-devtest |
secret-scan |
secret-scan.yml |
Push to main, every PR, manual |
gitleaks |
template |
template.yml |
Push to main, v* tags, every PR, manual |
verify, then publish |
| Event | Build and test | Deploy to devtest | Publish template to nuget.org |
|---|---|---|---|
| Pull request | Yes | No | No |
Push to main |
Yes | No | No |
Push a v* tag |
Yes | Yes | Yes |
Manual run on main |
Yes | Yes, with the footer showing main-<short sha> |
No |
Manual run on a v* tag |
Yes | Yes, as a redeploy of that release | No |
| Manual run on any other branch | Yes | No | No |
One tag therefore ships the demo and the NuGet package from the same commit. devtest and template cancel superseded runs on pull requests only. A run already in progress on main or on a tag isn’t cancelled: a newer run for the same ref waits for it.
devtest: build, test and deploy
Section titled “devtest: build, test and deploy”The test job is the gate for everything. deploy-devtest runs only after it passes, and only for a tag or a manual run on main.
The test job
Section titled “The test job”It runs on ubuntu-latest with .NET 10.0.x and Node 24. NuGet packages are cached on a hash of Directory.Packages.props.
| Step | Command | Checks |
|---|---|---|
| Restore | dotnet restore AppointMe.sln |
Every package resolves |
| Build | dotnet build AppointMe.sln -c Release --no-restore |
The solution compiles in Release |
| Test | dotnet test AppointMe.sln -c Release --no-build |
Every test project passes |
| Wolverine codegen check | dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen test |
Wolverine generates and compiles all handler code in memory, the same code the Docker build pre-generates |
| Frontend install | npm ci in src/AppointMe.Frontend |
The lockfile matches package.json |
| Frontend check | npm run check (vp check) |
Formatting, lint and type-check |
| Frontend build | npm run build (vp build) |
The production bundle builds |
The deploy job
Section titled “The deploy job”It targets the devtest GitHub environment, and its URL comes from the APP_PUBLIC_URL variable.
| Step | What it does |
|---|---|
| Azure login (OIDC) | azure/login@v3 with AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID |
| Resolve version | APP_VERSION is the tag name for a tag, or <branch>-<7-char sha> otherwise |
| Build image | az acr build in the registry, tagged appointme-api:<8-char sha>, :<APP_VERSION> and :latest, with --build-arg APP_VERSION |
| Point the Web App at it | az webapp config container set to the <8-char sha> tag, so a moved Git tag can’t silently change what’s running |
| Restart | az webapp restart forces the pull. EF migrations run when the container starts. |
The if: that decides whether it deploys:
if: >- (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')) # environment.url may not contain secret-derived values (APP_SERVICE_NAME is # a secret), so it comes from the optional APP_PUBLIC_URL repo variable. environment: name: devtest url: ${{ vars.APP_PUBLIC_URL }}The container image
Section titled “The container image”src/AppointMe.Api/Dockerfile builds from the repo root in three stages:
| Stage | Base image | Does |
|---|---|---|
frontend |
node:24-alpine |
npm ci and npm run build, with VITE_APP_VERSION set from the APP_VERSION build arg (default local) |
build |
mcr.microsoft.com/dotnet/sdk:10.0 |
dotnet restore, then codegen write in the Codegen environment, then dotnet publish in Release |
| runtime | mcr.microsoft.com/dotnet/aspnet:10.0 |
Copies the published API, puts the SPA in wwwroot, listens on http://+:8080 as $APP_UID |
Outside Development, Wolverine loads handler types statically (TypeLoadMode.Static), so the image depends on the code that codegen write generated. That’s why the test job runs codegen test on every push, not only when an image is built.
template: smoke-test the dotnet new package
Section titled “template: smoke-test the dotnet new package”verify packs the template, generates a project from the package, and builds, tests and lints the generated output. publish pushes that exact package to nuget.org on a pushed tag.
verify runs templates/smoke-test.sh with KEEP=1, then uploads the .nupkg as the template-package artifact. The script checks:
| Area | Checks |
|---|---|
| Package contents | Local-only files and withheld paths (appsettings.Devtest.json, .wrangler, Keycloak certs) are absent. Tracked dotfiles are present. The file list matches git ls-files minus the withheld paths. |
| Generation | dotnet new appointme -n Contoso.Booking succeeds, with no bin, obj or node_modules in the output |
| Rename | No AppointMe or lowercase brand token survives, the domain word Appointment is intact, and the Keycloak realm name matches its file name |
| Backend | The generated solution restores, builds in Release and runs the expected number of tests |
| Frontend | npm ci, npm run check and npm run build pass in the generated frontend |
| README | The generated README has your project’s name, no live-demo section and no broken image links |
publish runs only when a v* tag is pushed with git push, never on a manual run. It downloads the artifact that verify built and fails unless the tag matches the <Version> in templates/AppointMe.Templates.csproj. Then it pushes with --skip-duplicate. It authenticates through nuget.org Trusted Publishing: NuGet/login@v1 swaps the job’s OIDC token for a single-use key that’s valid for one hour. That needs the nuget environment, restricted to v* tags, and a NUGET_USER environment secret.
template.yml is excluded from the package, so a project generated from the template doesn’t get a job that publishes to BravoDev’s package ID.
Security scans: CodeQL and gitleaks
Section titled “Security scans: CodeQL and gitleaks”CodeQL scans both languages without a build. gitleaks scans the whole Git history, not only the diff.
| Workflow | What runs | Results |
|---|---|---|
codeql |
github/codeql-action v4 for csharp and javascript-typescript, with build-mode: none |
Security → Code scanning |
secret-scan |
The gitleaks 8.30.1 binary: gitleaks git . --config .gitleaks.toml --no-banner --redact, after checking out with fetch-depth: 0 |
Fails the run, with secrets redacted in the log |
.gitleaks.toml runs the default ruleset with an allowlist that matches by value, not by file. It allows only these committed values: the local SQL Server sa password, the two local Keycloak client secrets, the Service Bus emulator placeholder SAS_KEY_VALUE, and the public role ID of the built-in Key Vault Secrets User role. A real secret added to any of those files is still caught.
The workflow runs the gitleaks binary directly instead of gitleaks-action, which needs a license key for organization repos.
Azure login over OIDC
Section titled “Azure login over OIDC”No Azure credential is stored in GitHub. The deploy job swaps a short-lived GitHub token for an Azure token, and only the devtest environment can get one.
| Piece | Value |
|---|---|
| Workflow permission | id-token: write (plus contents: read) |
| Identity | User-assigned managed identity id-appointme-devtest-ci |
| Federated credential subject | repo:<owner>/<repo>:environment:devtest |
| Audience | api://AzureADTokenExchange, the azure/login default |
Who can run in devtest |
The environment’s rule allows v* tags and main only |
| Roles | Contributor on the registry, Website Contributor on the Web App |
Deploy to Azure with Bicep has the commands, and the six secrets and one variable the job reads.
Why does the environment rule matter more than the workflow's if?1 min · optional
A pull request runs the workflow file from its own branch, so it could edit the if: on deploy-devtest. What it can’t change is the devtest environment’s deployment rule, which lives in the repo settings.
Because the federated credential trusts only the environment:devtest subject, a job that isn’t admitted to the environment can’t get an Azure token at all. The nuget environment protects publish the same way.
Dependabot
Section titled “Dependabot”Weekly updates for NuGet, npm and GitHub Actions. Minor and patch bumps are grouped, majors arrive one per package, and security updates skip both the grouping and the cooldown.
| Ecosystem | Directory | Groups | Ignored |
|---|---|---|---|
github-actions |
/ |
github-actions: every action in one PR |
None |
npm |
/src/AppointMe.Frontend |
npm-minor-and-patch |
vite-plus, vite, @voidzero-dev/vite-plus-core |
nuget |
/ |
aspire (Aspire.*), nuget-minor-and-patch |
None |
Every ecosystem has a 7-day cooldown, which skips releases younger than a week. The three Vite+ entries in package.json have to move together, and Dependabot can only move one, so they’re bumped by hand. The Aspire group gets its own PR because Aspire.AppHost.Sdk in AppointMe.Aspire.csproj isn’t a package reference.
Troubleshooting
Section titled “Troubleshooting”Each check has a local equivalent, so reproduce the failure before you push again.
| Symptom | Cause | Fix |
|---|---|---|
| The Wolverine codegen check fails | Wolverine can’t generate code for a handler | Run dotnet build AppointMe.sln -c Release, then the same codegen test command locally |
| Frontend check fails on formatting | Vite+ enforces formatting | npm run format in src/AppointMe.Frontend, then npm run check |
verify fails: generated solution ran N tests, expected M |
You added or removed tests | Update EXPECTED_TOTAL_TESTS in templates/smoke-test.sh |
verify fails: generated output still contains the PascalCase token AppointMe |
A new file uses the brand token somewhere the rename doesn’t cover | Follow the hint in the failure message, which points at .template.config/template.json |
publish fails: Tag vX does not match the packaged version Y |
<Version> wasn’t bumped |
Bump it in templates/AppointMe.Templates.csproj, or tag the packaged version |
secret-scan fails |
A secret-like value exists in some commit, even an old one | Rotate the secret. If it’s a local-only placeholder, add the value to the .gitleaks.toml allowlist. |
deploy-devtest is skipped |
The run was a PR or a push to main |
Push a v* tag, or run the workflow manually on main |