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

Source page: https://appointme.dev/docs/deploy/ci-cd/

* **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

**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

**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

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

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

```yaml
    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

`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

**`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

**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

**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](/docs/deploy/azure/#provision-and-deploy) 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?

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

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

Gotcha

Dependabot doesn’t update container image tags. The SQL Server and Mailpit images are pinned in both `compose.yaml` and `src/AppointMe.Aspire/Program.cs`, so bump them by hand, together.

## 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`                                               |

## Next

* [Deploy to Azure with Bicep](/docs/deploy/azure/) (Deploy)
* [Production checklist](/docs/deploy/production-checklist/) (Deploy)
* [Take updates from upstream](/docs/guides/upstream-updates/) (Guide)
