Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

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

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.

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.

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

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:

.github/workflows/devtest.ymlView source ↗
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 }}

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.

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.

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.

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.

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