/

Developer Guide

CI/CD Pipeline

CrowdVision uses a fully automated CI/CD pipeline on GitHub Actions. Every pull request to master is gated by a suite of checks — builds, tests, linting, security audits, Docker and Kubernetes validation, and a guard that refuses to let any unreviewed path merge unchecked. Merging to master keeps a standing release pull request up to date; merging that release PR cuts a versioned release and pushes Docker images to GHCR. This page documents the workflows; the commit format and release mechanics that drive them are covered in Commits & Release Please.


Workflow naming

All workflow files live in \.github/workflows/ and use a three-prefix convention that mirrors their purpose.

PrefixCategoryWhen it runs
ci-*Continuous integration checksOn every pull request to master
cd-*Continuous deliveryOn every push to master
maint-*MaintenanceWeekly schedule or manual dispatch

The CI gate

Every pull request must pass CI Gate / CI Passed — the single aggregated check that covers all the fast CI. The gate (ci-gate.yml) detects what changed, fans out to the relevant checks in parallel, and reports one result. The only other check worth requiring in branch protection is CodeQL (the Static security analysis section below), which runs as a separate workflow because it needs wider permissions than the gate’s read-only token.

graph LR
    PR[Pull Request] --> CHANGES[changes<br/>path filter]
    CHANGES --> SVC[Service legs<br/>chat · agent · frontend<br/>contracts · notification<br/>sensor · socket · twin<br/>claims-gateway · tenancy-service<br/>registry-service · provisioner]
    CHANGES --> DOCKER[docker<br/>hadolint + build]
    CHANGES --> K8S[k8s<br/>kubeconform · pluto · kube-score]
    CHANGES --> LINT[workflows-lint<br/>actionlint]
    CHANGES --> INFRA[infra<br/>scripts + tooling]
    SVC --> PASSED[CI Passed]
    DOCKER --> PASSED
    K8S --> PASSED
    LINT --> PASSED
    INFRA --> PASSED
    PASSED --> MERGE[merge allowed]

How the gate decides

The changes job runs dorny/paths-filter once and emits a boolean per area. Every downstream leg is guarded by an if: on those booleans, so a leg runs only when a path it owns actually changed. Service legs and the cross-cutting checks are called as reusable workflows (uses: ./.github/workflows/ci-*.yml); the gate itself stays a thin orchestrator — detect, fan out, aggregate.

Unaffected legs are skipped, not failed

A leg whose paths did not change resolves as skipped, which the aggregator counts as a pass. This is what keeps the pipeline fast — a one-line change to one service does not rebuild the world.

The final ci-passed job depends on every leg and runs with if: always(), so it reports even when a leg was skipped. Its verdict logic is deliberately strict:


Service CI workflows

Each service has a dedicated reusable workflow that the gate calls when that service’s directory (or its own workflow file) changes. Each installs dependencies, lints, builds, runs tests, and audits its dependencies.

WorkflowServiceLanguage / test runner
ci-chat.ymlbackend/chat-serviceNode.js 24 / Jest
ci-twin.ymlbackend/twin-serviceRust stable / cargo llvm-cov
ci-sensor.ymlbackend/sensor-serviceNode.js 24 / Jest
ci-socket.ymlbackend/socket-serviceNode.js 24 / Jest
ci-notification.ymlbackend/notification-serviceNode.js 24 / Jest
ci-contracts.ymlbackend/contracts-serviceRust stable / cargo llvm-cov
ci-frontend.ymlfrontendNode.js 24 / Vitest
ci-agent.ymlbackend/agent-servicePython 3.12 / pytest
ci-claims-gateway.ymlbackend/claims-gatewayGo 1.25 / go test
ci-tenancy-service.ymlbackend/tenancy-serviceGo 1.25 / go test
ci-registry-service.ymlbackend/registry-serviceGo 1.25 / go test
ci-provisioner.ymlbackend/provisionerGo 1.25 / go test

The Node services (chat, sensor, socket, notification) share the same shape: npm ci, install the shared tooling/eslint-config/, lint, build, test with coverage, then audit. The frontend splits into a quality job (lint, type-check, audit), a test job that shards Vitest across three parallel runners, and a coverage job that runs the suite unsharded to produce a single coverage report. The two Rust services (contracts-service, twin-service) both run cargo fmt --check and cargo clippy --all-targets -- -D warnings before cargo llvm-cov, then a rustsec/audit-check; twin-service’s job additionally installs mold and spins up a real mongo:7 service container, since its integration tests exercise MongoDB directly rather than mocking it. The agent runs ruff check, ruff format --check, and pyright in a quality job, with pytest (unit + integration, with --cov) in a separate tests job. The four Go services each run go vet (lint), go build, then go test -coverprofileactions/setup-go’s go-version-file picks up each service’s own pinned version (provisioner is still on Go 1.23, the other three on 1.25); none of them run an audit step yet (see the note below). Coverage reporting is detailed in the Code coverage section below.

The dependency audit is split into two steps

Every Node service runs the audit twice. npm audit --audit-level=high --omit=dev is blocking — a fixable high/critical vulnerability in a production dependency fails the build. npm audit --audit-level=high (the full tree, including dev deps) runs straight after with continue-on-error: true, so dev-only advisories are surfaced in the log without blocking a merge. The split keeps the gate honest about what ships to users while still reporting everything.

The four Go services don’t have an audit step yet

Unlike every other language here, there’s no govulncheck-equivalent step in the four Go CI workflows — \.moon/tasks/go.yml doesn’t define an audit task either, so just setup audit silently skips them too (see The Toolchain. Build/lint/test coverage is in place; dependency vulnerability scanning for Go is still open.


Code coverage

Every service CI workflow now measures code coverage as part of its test run and reports it on each pull request — collected with each stack’s native tooling, with no external coverage service involved.

StackCommandReport format
Node.js services + frontendjest --coverage · vitest run --coverageIstanbul coverage-summary.json
Rust servicescargo llvm-cov --jsonLLVM JSON
agent-servicepytest --covCobertura coverage.xml
Go servicesgo testCobertura coverage.xml

The Node and Rust services write a coverage summary into the GitHub job summary (rendered from the report with jq); the agent surfaces its figures directly in the pytest log via --cov-report=term-missing.

The coverage summary runs always

The step that publishes the coverage table is guarded by if: always(), so it runs even when a test fails — by default a failed step skips every step after it, yet the coverage of a red run is often exactly when you want to read it. This does not weaken the gate: always() governs only that one reporting step, so a failing test still fails the job. Coverage is report-only for now — no minimum threshold is enforced, so it surfaces the numbers on every run but cannot itself block a merge.


Simulator CI

The two device simulators ship as GHCR images but are not application services, so they get a dedicated ci-simulators.yml — one job each, gated on simulators/.

JobRuntimeChecks
sensor-simulatorNode.js 24 / TypeScriptESLint (shared config) + tsc build. No test suite exists yet, so there is no test step — a bare Jest with zero tests would fail.
aq-simulatorPython 3.12py_compile parses every module (smoke.py is not executed — it performs network I/O), then ruff check runs with default rules, which catch real defects such as undefined names and unused imports.

Both were previously ungated despite shipping images until this workflow gave them a real bucket.


Cross-cutting checks

These legs validate properties that span the codebase rather than a single service. They are gated by their own path filters and join the same ci-passed aggregator.

LegWhat it validates
ci-docker.ymlThe docker leg. For each changed service that ships an image: hadolint on its Dockerfile, then a push: false Docker build (a PR-time build check — cd-registry does the real publish on master). The gha layer cache is scoped per image so services reuse their own layers without evicting each other.
ci-k8s.ymlThe k8s leg. Validates k8s/ with kubeconform (schema, against Kubernetes 1.32.0, -strict, plus the CRDs-catalog schema location so Istio/Gateway API kinds — Gateway, HTTPRoute, PeerAuthentication, RequestAuthentication, AuthorizationPolicy — are actually schema-checked rather than erroring with “could not find schema”), pluto (deprecated/removed API versions), and kube-score (best-practice scoring, with a curated set of ignored tests).
workflows-lintA job defined inline in ci-gate.yml that validates the workflow files themselves whenever anything under the &#46;github/ tree changes. actionlint (digest-pinned container) catches malformed expressions and invalid action inputs; shellcheck is bundled but disabled (-shellcheck=) until its run:-block findings are triaged. zizmor (pinned version, run --offline --min-severity medium) then audits for security smells actionlint misses: template injection in run: blocks, credential persistence (it is why every read-only checkout sets persist-credentials: false), over-broad token permissions, and dangerous triggers.
ci-infra.ymlThe infra leg. Cheap, real validation for repo scripts whenever scripts/ or tooling/ change: node --check parses every tracked &#46;mjs/&#46;js, and bash -n checks every shell script. Keeps routine script edits green automatically.
ci-dependency-review.ymlThe dependency-review leg. On any PR that changes a dependency manifest or lockfile, it fails if the PR introduces a dependency carrying a known high/critical advisory (fail-on-severity: high). It complements the per-service audit steps: those see the merged lockfile state, whereas this diffs exactly what the PR adds, catching a vulnerable package at the moment it is introduced. Requires the repository’s dependency graph (on by default for public repos).

Every third-party action is pinned to a commit SHA

Each uses: across every workflow references a full 40-character commit SHA (with the human-readable version kept as a trailing comment, e.g. # v6), never a moving tag like @v4. A tag can be silently repointed at malicious code by whoever controls the action; a commit SHA cannot. The repository setting Settings → Actions → “Require actions to be pinned to a full-length commit SHA” enforces this, so no future workflow can reintroduce an unpinned action. Local reusable workflows (uses: ./.github/workflows/…) are exempt — they live in this repo. Bumping a version means resolving the new tag to its SHA (git ls-remote) and updating both the SHA and the comment.


CD workflows

cd-release

Triggered on every push to master. Runs googleapis/release-please-action, which reads commits since the last tag and either opens/updates a release pull request (CHANGELOG.md + every version manifest) or, if its own release PR was just merged, tags the repository and publishes a GitHub Release. A concurrency guard prevents duplicate runs. The full mechanics are in Commits & Release Please.

cd-registry

Triggered on push to master when source changes under backend/**, simulators/**, frontend/**, or the workflow file itself. It publishes Docker images to GHCR tagged both :latest and :<git-sha>, for all fourteen images — the ten TypeScript/Rust/Python services and simulators, plus claims-gateway, tenancy-service, registry-service, and provisioner.

The publish is gated by a vulnerability scan that runs before anything reaches the registry. Each image is built into the runner’s local daemon (push: false, load: true), scanned by Trivy, and pushed only if the scan passes — so a vulnerable image is never published. The push step rebuilds from the still-warm gha cache, so it costs no recompilation.

Build → scan → push, not push → scan

Scanning after publishing would mean a flagged image is already live by the time the job fails. Ordering the scan as a gate before the push is the whole point. aquasecurity/trivy-action is pinned to a commit digest (not the moving @master) to remove that supply-chain risk.

Selective builds: only changed services are pushed

The workflow does not rebuild every image on every push. A changes job detects which services changed and emits a dynamic build matrix; a build-and-push job (needs: changes) then builds only the selected images, and is skipped entirely when nothing matches. Two cases force a full rebuild: a change to cd-registry.yml itself, and a manual workflow_dispatch with service: all (or a comma-separated list to target specific images).

Scan scope

The Trivy scan is deliberately scoped to OS packages only (vuln-type: os). Application dependencies are not re-scanned here because they are already gated pre-merge by the per-service audits. The division of labour is: the service CI workflows own language dependencies at PR time; cd-registry Trivy owns the base-OS layer at push time — the part the dependency audits cannot see.

To keep the OS scan green, every production image upgrades its base-OS packages at build time (apk upgrade on Alpine images, apt-get upgrade on the Debian-based contracts, agent, and aq-simulator images). The Debian upgrade lines carry # hadolint ignore=DL3005. Any residual fixable CVE that cannot be patched in-build is waived in the repo-root \.trivyignore, where each entry must carry a justifying comment.


Static security analysis (CodeQL)

ci-codeql.yml runs GitHub’s SAST for javascript-typescript, python, rust, and go with the security-extended query suite. It runs automatically on every pull request to master, on every push to master (to refresh the Security tab’s default-branch baseline), and on a weekly schedule (Monday 03:00 UTC) to catch CVEs newly disclosed against unchanged code. Manual workflow_dispatch is retained for on-demand re-scans, and a concurrency guard cancels a scan superseded by a newer commit.

All four languages use build-mode: none — CodeQL extracts from source without a compile. For javascript-typescript and python that is the natural mode; for rust (contracts-service and twin-service) and go (claims-gateway, tenancy-service, registry-service, provisioner) it means no toolchain setup is needed for CodeQL’s own purposes, and each of the several independent crates/modules is found and extracted directly from source without a root Cargo.toml/go.work tying them together.

It runs as a standalone workflow rather than a leg of the gate, on purpose: CodeQL needs security-events: write to upload results, whereas the gate’s token is deliberately read-only, and a reusable workflow cannot hold more permission than its caller. Keeping CodeQL separate preserves the gate’s least-privilege token. Add CodeQL as a second required status check in branch protection (alongside CI Gate / CI Passed) to block merges on new high-severity findings.


Maintenance

maint-cleanup runs every Sunday at 02:00 UTC (or on manual dispatch). It removes old package versions from GHCR, keeping a minimum of three versions per image and protecting the :latest tag, so the registry does not grow unbounded.


Dependency updates

Dependency upgrades are made manually (there is no Dependabot). Bump the version in the relevant manifest, then verify and regenerate locks locally before opening a pull request:

just setup deps-check   # every lockfile in sync (npm ci / uv sync --locked / cargo check)
just setup audit        # no known vulnerabilities (npm / uv / cargo audit)

Both checks also run inside the per-service CI workflows on every pull request, so a drifted or vulnerable dependency blocks the merge regardless.


Docker image registry

All images are hosted at ghcr.io/nickghignatti/ and tagged both :latest and :<git-sha>, published automatically by cd-registry on every push to master that touches them. The Kubernetes deployment manifests reference :latest.

ServiceGHCR image
chat-serviceghcr.io/nickghignatti/crowdvision-chat
twin-serviceghcr.io/nickghignatti/crowdvision-twin
sensor-serviceghcr.io/nickghignatti/crowdvision-sensor
notification-serviceghcr.io/nickghignatti/crowdvision-notification
socket-serviceghcr.io/nickghignatti/crowdvision-socket
contracts-serviceghcr.io/nickghignatti/crowdvision-contracts
agent-serviceghcr.io/nickghignatti/crowdvision-agent
frontendghcr.io/nickghignatti/crowdvision-frontend
sensor-simulatorghcr.io/nickghignatti/crowdvision-simulator
aq-simulatorghcr.io/nickghignatti/crowdvision-aq-simulator
claims-gatewayghcr.io/nickghignatti/crowdvision-claims-gateway
provisionerghcr.io/nickghignatti/crowdvision-provisioner
registry-serviceghcr.io/nickghignatti/crowdvision-registry
tenancy-serviceghcr.io/nickghignatti/crowdvision-tenancy

Reproducing the gates locally

Run the same checks CI runs before pushing, to avoid a slow round-trip through a failed pipeline:

just test all            # all unit tests (moon-cached); or just test affected
just lint affected   # ESLint / Ruff on changed projects
just setup deps-check      # lockfile sync
just setup audit           # vulnerability audit

Per-service tests are available as just test chat, just test twin, just test sensor, just test notification, just test socket, just test frontend, and just test agent (no per-service recipe for contracts-service or the four Go control-plane services — use just test affected/all, which cover every registered project via moon run :test). Full backend integration tests run via just test integration (it composes the full stack — see Docker Compose Orchestration.