Skip to content

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")]
Hold "Alt" / "Option" to enable pan & zoom

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 its bmo-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.json edit ("version": "2.1"), merged to main. The height restarts at the change. A release tag v2.1 marks it in git. publicReleaseRefSpec makes versions from main and v* tags public (no -g<commit> suffix); builds from other branches carry the suffix.
  • A BMO release is a tag bmo-vX.Y.Z on main (.claude/rules/bmo.md). Never a bare v* tag, and never git push --tags after fetching from a BMO remote. The release notes are generated from the previous bmo-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: true from its job.
  • .github/gitleaks.toml is the default rule set plus both existing allowlists: GitLab's .gitlab/gitleaks-allowlist.toml, and bmo/.gitleaks.toml with its paths under bmo/. 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.md and .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:

  1. Make GitHub the primary remote: stop the dual push, and remove GitLab's push mirror or reverse it (GitHub → GitLab).
  2. Add a ruleset on main: pull requests required, the check CI passed required, CODEOWNERS review.
  3. ci.yml and security.yml: narrow push to main, add pull_request and merge_group (dependency review needs pull_request).
  4. Dependabot: raise open-pull-requests-limit.
  5. Move .gitlab/ci/aot-warnings-baseline.json to ci/, and update scripts/aot-warnings.ps1 and the docs that name it.
  6. Remove .gitlab-ci.yml and .gitlab/ (keep the allowlist values in .github/gitleaks.toml), and update mkdocs.yml (repo_url), the README badges and the links to GitLab.
  7. Point compose and the VM deployments at the ghcr.io images, with a read:packages pull 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 .git is only a pointer.
  • The version CI would stamp: dotnet tool run nbgv get-version (CommandCenter), or minver --tag-prefix bmo-v (BMO, dotnet tool install -g minver-cli).