Contributing and CI¶
This page describes contribution expectations and automated pull-request checks.
When to use this page¶
Use this page before opening a pull request and when matching local checks to CI.
Branch and pull-request basics¶
- Open pull requests against
mainunless the repository workflow says otherwise. - Keep changes scoped and explain the user-visible or operational impact.
- Update tests and documentation when behaviour, configuration, or interfaces change.
- Do not commit credentials, tokens, private keys, or local runtime environment files.
Current automated checks¶
This table is the single inventory of GitHub workflows. infra/tests/test_docs.py
fails when a workflow file is missing here or the release gate is not marked.
Every check that gates a merge or a release is a job in ci.yml. Its ci-gate job
needs all other jobs, so it is the one check the branch rule requires. It succeeds
only when the changes job wrote a plan, every job the plan selected succeeded and
every job it did not select was skipped; a failed, cancelled or unplanned result
fails it. Job names may carry matrix values such as a Python or EMQX version; a
version bump renames such a job without touching the rule, and
infra/tests/test_ci_workflow.py fails if a new job is left out of ci-gate or the
plan.
| Workflow file | Name | Triggers | Checks | Release gate |
|---|---|---|---|---|
ci.yml |
CI | Pull requests, main, merge queue, manual |
ci-gate (required) needs every job: changes (plans the run); static and static-release (focused content checks and verified image reuse for the static plan); lint (pre-commit, the ty type check and generated contracts, once); contracts (tests that read documentation or frontend sources, only when the full suites are planned out); backend (3.12), backend (3.14) (backend tests); frontend (lint, tests and build); frontend browser (route-mocked journeys against that checked build); infrastructure (Ansible syntax, rendered stack and infra/tests); collection (5.8.7), collection (6.3.2) (postgres lane and real EMQX brokers); radar (radar_integration lane on Swarm with this run's RADAR image); images on pull requests, the merge queue and manual runs (builds every image once, checks the frontend's delayed API start and keeps a run-scoped archive) or publish on main (pushes and attests the images); deployment-smoke (rehearsal) (every deployment test on a production Swarm: the adaptive monitor, then backup restore and binary rollback over state the test creates) and deployment-smoke (browser) (every browser journey on a second Swarm, visual pipelines last). On main RADAR and both smoke lanes test the images publish pushed |
yes |
docker-images.yml |
Docker Images | Called by CI on main |
Publishes and attests the image matrix, including the strict Zensical docs build | no |
deploy-production.yml |
Deploy Production | Completion of CI on main, manual |
Deploys the newest main commit whose CI push run succeeded and kept its production-release evidence (Production releases) |
no |
ghcr-retention.yml |
GHCR Retention | Weekly, manual | Lists application image versions beyond the newest 20 releases per image, keeping latest, the committed release pins and the kept images' attestations; deletes them on a manual run with delete or weekly once the variable GHCR_RETENTION_ENABLED is true (Image retention) |
no |
pr-triage.yml |
PR triage | Pull requests opened, reopened or marked ready | Assigns a human author and requests the maintainer's review; never fails | no |
| CodeQL default setup | CodeQL | Repository settings | CodeQL |
no |
Shared steps live in .github/actions/setup-backend (Python, the pinned uv release
and the locked backend sync). Test lanes select their tests by pytest marker; see
Testing and debugging.
Planned runs¶
.github/scripts/ci_plan.py selects checks from the complete change:
| Changed paths | Jobs that run |
|---|---|
Only root-level *.md |
static: generated asset checks, public template rendering and documentation navigation/link checks |
| Landing HTML, Markdown, agent index, embedded hero, images and the landing exporter | static; on main, static-release |
Published content in docs/** or zensical.toml, optionally alongside landing changes and root Markdown |
static, including a strict docs image build and HTTP/asset/export probe; on main, static-release |
frontend/**, optionally root Markdown, except frontend/Dockerfile, .dockerignore, .npmrc, package.json and package-lock.json |
lint, frontend, frontend browser, contracts, images or publish, both deployment-smoke lanes |
| Backend, dependencies, shared brand assets, docs build programs, other infrastructure/CI changes, mixed frontend/static changes, or unknown paths | every application suite and full release rehearsal |
Static changes skip backend type checking and tests, frontend browser suites, broker tests, RADAR, observability and Swarm rehearsals. The allowlist names the four landing templates explicitly; changing nginx or deployment configuration still runs the full plan. The landing exporter is included so selecting a new hero photograph stays a fast change.
On main, static-release carries forward the exact immutable application image
digests from a successful ancestor's production-release artifact. It independently
checks the complete diff and every reused image's committed build inputs. Changed
docs are built and probed once, transferred using a run-specific SHA-256-verified
archive, then published and attested; the registry image config must match the
checked image. Reused digests receive this release's tags to preserve retention.
The resulting production-release artifact keeps ordinary automatic deployment
working. This does not change production maintenance, readiness or rollback rules.
Pull requests compare their merge commit with its base parent, the merge queue
compares the group with its base, and manual runs always run every job. A main
push compares with the newest ancestor whose CI run kept the production-release
artifact, so a cancelled or superseded run never hides a change. Deletions and
renames count both paths. An empty or unreadable diff, an unexpected checkout or
an API failure selects every job. infra/tests/test_ci_plan.py covers these
cases, including a landing push following a cancelled backend run.
A root-Markdown-only main run succeeds without publishing or rehearsing images,
so it keeps no release evidence; production release selection
skips it. Cheap checks run first: backend, infrastructure, collection,
observability, image and RADAR jobs wait for lint, and the deployment rehearsals
also wait for frontend. The independently required frontend browser job verifies
and unpacks that run's checked build using its SHA-256 output, then runs mocked
journeys while the Swarms prepare. Mocked journeys run once with two isolated
workers; production journeys keep one worker for shared catalogue/member state.
The input-picker expiry test controls only browser time over real MQTT receipts;
the sidebar journey still exercises the full two-minute expiry in real time.
make -C infra check-deploy runs each infrastructure test in a worker process of
its own, one per processor (infra/tests/run_suite.py). A class with a shared
fixture stays whole, and the run fails unless every discovered test ran. From
infra, python -m unittest discover -s tests is the serial equivalent.
Each rehearsal lane ends by reporting where its deployments spent their time: Ansible task names and durations only, as the production deployment log prints them, and the duration of the readiness checks between them.
Image builds¶
infra/scripts/image_sources.py selects each image's source revision from its
complete committed input paths. All backend paths are shared backend inputs;
frontend and documentation inputs are separate. Build-tool and workflow edits
invalidate every image. The selected revision's input tree must equal the tested
checkout's tree; shallow history disables reuse. Stable revision/version labels
let unchanged builds retain their digest instead of replacing it for each release.
Full/frontend plans still build and rehearse images; static plans reuse unchanged
tested digests as described above. Release evidence
records the source revision per image, and deployment recomputes these revisions
before checking the registry labels. Older release evidence retains its original
all-images-at-the-release-SHA validation.
On pull requests, the merge queue and manual runs, the images job builds each
application image once with the inputs the rehearsals test (the production
frontend target, the service build arguments, GIT_COMMIT and the
org.opencontainers.image.revision label, all from
infra/tests/rehearsal_releases.py). It exports them as one zstd archive, kept as
a one-day artifact of that run only, and passes the archive digest and every image
ID as job outputs. RADAR and both rehearsal lanes load the archive only after the
digest, image IDs and revision labels match those outputs, then push it to their
own disposable registry; CI never rebuilds a current image elsewhere. Pull
requests never receive registry write credentials and their archive is never
promoted. On main, publish pushes and attests the images with the same build
inputs, and RADAR and the rehearsals test those published digests, which the
release then deploys.
Image builds read the validation cache and the publishing cache on main. BuildKit
still checks the Dockerfile, build arguments and input files; a cache hit does not
skip any test. The compatible rollback release is still built from its separately
reviewed source revision, with an additional cache scoped to that revision and
service. Current-image cache writes remain owned by the images and publish
jobs, avoiding competing exports from lanes.
.github/actions/run-with-build-cache gives CLI builds the cache runtime
credentials GitHub supplies to JavaScript actions. They are inherited only by that
command and its children, not written to GITHUB_ENV for subsequent test steps.
Local rehearsals keep using ordinary Docker builds without GitHub credentials.
Backend images retain the complete installed runtime but put workspace packages and third-party dependencies in separate layers. Source-only changes can reuse the larger dependency layer on registry pushes and node pulls. This reduces update traffic and repeated storage; the first installation remains approximately the same size.
Local quality gate¶
Match local work to the changed surface. For landing copy, CSS and images, run
python assets/brand/export.py --check, python assets/landing/export.py --check
and uv run --project infra python infra/scripts/check_static.py; inspect desktop
and mobile screenshots when layout changes. Skip application tests. For published
docs, use a strict docs build and dev/docs/check_image.py against the built image.
Root Markdown edits need only formatting/link review. Run targeted component tests
for behavior changes and the broader gate below for shared/runtime/CI changes.
uv sync --project backend --all-packages --all-groups --frozen
uv run --project backend ruff check .
uv run --project backend ty check --project backend
uv run --project backend python -m pytest -q
uv run --project backend pre-commit run --all-files
npm --prefix frontend ci
npm --prefix frontend run lint
npm --prefix frontend test
npm --prefix frontend run build
python -m pip install -r requirements-docs.txt && zensical build --clean --strict
Preview the documentation with zensical serve.
Run the smallest relevant tests while developing, then run the complete gate before requesting review.
Commit hygiene¶
- Keep generated artifacts out of commits unless the project explicitly requires them.
- Preserve lockfile changes when dependency resolution intentionally changed.
- Treat
.env, encrypted production vaults, inventories and credentials, and production endpoint details as local or secret-store data. - Review
git diff --checkand the staged diff before pushing.