CI/CD: GitHub Actions and GitLab CI#
The repository builds, tests and publishes on GitHub Actions (.github/workflows/). GitLab CI
(.gitlab-ci.yml) still runs the same checks in parallel until GitHub has proven itself; the
switch checklist retires it.
origin pushes every branch and tag to both GitLab and the GitHub mirror (bcs-bv/command-center),
so a push starts both. Merge requests stay on GitLab for now, and nothing is merged on GitHub:
GitHub's main must stay a mirror of GitLab's, or the next dual push is rejected.
Prerequisite: a GitLab push mirror#
The dual push only carries what someone pushes from a checkout. A merge request merged on
GitLab's server never reaches GitHub that way, and neither does a tag made in GitLab's UI. Without
a mirror, GitHub's main falls behind: 47 commits on 2026-09-29, and no bmo-v* tags. Then nothing
that runs on main or on a release tag runs on GitHub: image publishing, BMO's packages and
releases, the docs deploy.
In GitLab (Settings → Repository → Mirroring repositories), add a push mirror:
- URL
https://github.com/bcs-bv/command-center.git, password authentication. - The password is a GitHub fine-grained token for that repository with Contents: read and
write and Workflows: read and write. Without Workflows, GitHub refuses any push that
changes
.github/workflows. - Mirror all branches, or only protected ones (then feature branches keep arriving through the dual push).
The first mirror push brings main up to date and carries every tag, the 18 historic bmo-v*
included. GitHub creates no workflow events when more than three tags arrive in one push, so that
push does not release them again. A tag pushed on its own later does start bmo.yml.
flowchart LR
Dev([git push origin]) --> GL[GitLab CI]
Dev --> GH{GitHub Actions}
GH -- "every branch" --> CI["ci.yml<br/>tests, drift, ratchets"]
GH -- "every branch, weekly" --> SEC["security.yml<br/>gitleaks, Semgrep, Grype, Checkov, PSRule"]
GH -- "main, v*, bmo-v*" --> IMG["images.yml"] --> GHCR[("ghcr.io/bcs-bv/…")]
GH -- "main (bmo/**), bmo-v*" --> BMO["bmo.yml"] --> PKG[("GitHub Packages<br/>NuGet")]
BMO -- "bmo-v*" --> REL[("GitHub Release<br/>CLI + agent zips, SBOM")]
GH -- "main (docs/**)" --> DOCS["docs.yml"] --> SWA[("Azure Static Web Apps<br/>docs")]
GH -- "by hand" --> SPA["deploy-spa.yml"] --> SWA2[("Azure Static Web Apps<br/>SPA")]
The workflows#
| Workflow | Runs on | What it does | GitLab job it matches |
|---|---|---|---|
ci.yml |
every pushed branch, by hand | .NET tests (the whole solution, BMO's tests included, then the AOT ratchet), OpenAPI drift, OpenAPI drift (Windows) (SystemStats, only when it changed), BMO build (when bmo/ changed), SPA build and tests, OPA policy tests, Docs build (when docs changed), Workflow lint; CI passed sums them up |
dotnet:tests, openapi:drift, bmo:build, vitest:tests, opa:test |
security.yml |
every pushed branch, Mondays, by hand | See Security | SAST and Secret Detection templates |
images.yml |
main, tags v* and bmo-v*, by hand; other branches build only |
Six images to ghcr.io/bcs-bv, with versions |
release:image:*, build:dev:image |
bmo.yml |
main when bmo/** changed, tags bmo-v*, by hand |
BMO's tests, packages to GitHub Packages, and on a tag a GitHub Release | none (it was bmo/.github/workflows/build-and-release.yml, which never ran from there) |
docs.yml |
main when docs changed, by hand |
The MkDocs site to Azure Static Web Apps | pages, deploy docs |
deploy-spa.yml |
by hand | The SPA to Azure Static Web Apps | deploy (manual) |
Things to know:
- Full history. Every .NET job checks out with
fetch-depth: 0. Nerdbank.GitVersioning refuses a shallow clone, and MinVer mis-versions BMO without itsbmo-v*tags. - Microsoft.Testing.Platform.
dotnet test --solution CommandCenter.slnx, never--nologo(AGENTS.md → Gotchas). - Docker on the runner. GitHub's runners have Docker, so the Testcontainers tests (the App
Configuration emulator, Azurite) really run in
.NET tests. GitLab's image had no Docker and skipped them. Azurite is pinned (3.37.0) and started with--skipApiVersionCheck. - Windows runs only where it has to: SystemStats' OpenAPI (it targets
net10.0-windows) and BMO's release build. Windows minutes count double on a private repository. - Every action is pinned to a commit SHA (the version in a comment); Dependabot keeps them current.
Versions#
| What | Version from | Tags |
|---|---|---|
CommandCenter images (commandcenter, commandcenter-webapi, commandcenter-bff-frontdoor, commandcenter-frontend, commandcenter-opa) |
Nerdbank.GitVersioning: version.json (2.0) + the commit height |
on main: latest, v2.0.143, v2.0; on a tag v*: v2.0.143, v2.0; always sha-abc1234 |
bmo-commandcenter image |
MinVer on bmo-v* tags |
on main: latest, v2.3.1-alpha.0.4 (a prerelease); on a tag bmo-v2.3.1: v2.3.1, v2.3; always sha-… |
BMO packages (Benefits.Contracts, Benefits.Contracts.CloudEvents, Benefits.Agent.Client) |
MinVer | main pushes prereleases, a bmo-v* tag the release |
| BMO CLI and agent zips | MinVer | only on a bmo-v* tag, in its GitHub Release |
- A new CommandCenter minor version is a
version.jsonedit ("version": "2.1"), merged to main. The height restarts at the change. A release tagv2.1marks it in git.publicReleaseRefSpecmakes versions frommainandv*tags public (no-g<commit>suffix); builds from other branches carry the suffix. - A BMO release is a tag
bmo-vX.Y.Zon main (.claude/rules/bmo.md). Never a barev*tag, and nevergit push --tagsafter fetching from a BMO remote. The release notes are generated from the previousbmo-v*tag, so CommandCenter's commits in between are left out. - Images carry
org.opencontainers.image.version, and BuildKit provenance and an SBOM as attestations in the registry. - ghcr.io packages from a private repository are private: pulling them needs a token with
read:packages.
Security#
Code Security is off on the repository, so nothing goes to GitHub's Security tab. Each tool is a check with a job summary, and its report is kept as an artifact.
| Job | Tool | Blocks? |
|---|---|---|
| Secrets | gitleaks CLI (.github/gitleaks.toml) |
Yes, on a push: only the pushed commits are scanned. The weekly and manual runs scan all history and only report. |
| Code | Semgrep (p/security-audit, p/owasp-top-ten, p/csharp, p/typescript) |
Not yet |
| Dependencies | Syft SBOM + Grype, high and above | Not yet |
| Infrastructure | Checkov (Bicep) and PSRule for Azure (.github/ps-rule.yaml) on infra/ |
Not yet |
| CodeQL, dependency review | GitHub | Off: only with the repository variable CODE_SECURITY=true, which needs Code Security |
- Making a scanner blocking: once its report is clean (or its findings are triaged), remove
continue-on-error: truefrom its job. .github/gitleaks.tomlis the default rule set plus both existing allowlists: GitLab's.gitlab/gitleaks-allowlist.toml, andbmo/.gitleaks.tomlwith its paths underbmo/. Keep them in step while GitLab's Secret Detection runs. An allowlist names literal values or narrow paths. BMO's GUID and product-name regexes were left out, because they would hide any secret on a line that mentions one.- The full history has findings from before these checks, including keys and a token in settings files. They are listed in the weekly run's report. A leaked secret is rotated first; rewriting history is a separate decision.
Dependencies#
.github/dependabot.yml covers NuGet (/ with central package management, and /bmo), npm (the
pnpm workspace), GitHub Actions, the four Dockerfile folders and docs/requirements.txt. MassTransit
stays below 9 (license).
Until the switch, open-pull-requests-limit: 0: Dependabot computes the updates but opens no
pull requests, because nothing may be merged on GitHub yet. pnpm run / cc deps update stay the
way to update locally.
Secrets, variables and environments#
| Name | Kind | Where | Used by |
|---|---|---|---|
GITHUB_TOKEN |
automatic | — | ghcr.io pushes, GitHub Packages, releases, test reports |
AZURE_STATIC_WEB_APPS_API_TOKEN_DOCS |
secret | environment docs |
docs.yml. Without it the site is built and kept as an artifact, and the deploy is skipped with a warning. |
AZURE_STATIC_WEB_APPS_API_TOKEN_SPA |
secret | environment spa |
deploy-spa.yml |
CODE_SECURITY |
variable | repository | true switches CodeQL and dependency review on |
The two Static Web Apps tokens are the ones GitLab keeps as DEPLOYMENT_TOKEN_DOCS and
DEPLOYMENT_TOKEN. Add a required reviewer to an environment to put a gate before its deploy.
Repository files#
.github/pull_request_template.mdand.github/ISSUE_TEMPLATE/(bug and feature forms; blank issues off)..github/CODEOWNERS: enforced after the switch by a ruleset.SECURITY.md(root): how to report a vulnerability or a leaked secret. It replaces BMO's copy.
Switch checklist#
Not done yet. Do these in order once GitHub has been green next to GitLab for about two weeks:
- Make GitHub the primary remote: stop the dual push, and remove GitLab's push mirror or reverse it (GitHub → GitLab).
- Add a ruleset on
main: pull requests required, the checkCI passedrequired, CODEOWNERS review. ci.ymlandsecurity.yml: narrowpushtomain, addpull_requestandmerge_group(dependency review needspull_request).- Dependabot: raise
open-pull-requests-limit. - Move
.gitlab/ci/aot-warnings-baseline.jsontoci/, and updatescripts/aot-warnings.ps1and the docs that name it. - Remove
.gitlab-ci.ymland.gitlab/(keep the allowlist values in.github/gitleaks.toml), and updatemkdocs.yml(repo_url), the README badges and the links to GitLab. - Point compose and the VM deployments at the ghcr.io images, with a
read:packagespull token on the VMs. Then write the deployment workflow; today only the SPA's manual Static Web Apps deploy exists.
Locally#
- Lint the workflows:
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12. - Scan for secrets as CI does:
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:v8.30.1 git --config /repo/.github/gitleaks.toml --log-opts="origin/main..HEAD" /repo. In a git worktree, mount the main checkout: the worktree's.gitis only a pointer. - The version CI would stamp:
dotnet tool run nbgv get-version(CommandCenter), orminver --tag-prefix bmo-v(BMO,dotnet tool install -g minver-cli).