Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 141 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,51 @@ concurrency:
permissions:
contents: read

# One distribution, one verification graph — but the graph's stages are
# independent, so CI runs them as concurrent jobs instead of one serial job.
# Each stage fails fast on its own; a small change gets actionable hygiene/lint
# feedback in seconds; and SonarCloud starts as soon as coverage exists rather
# than after the whole graph. Coverage is unchanged: every stage stays
# mandatory because the `PR Gate` job aggregates them into the single required
# status check (branch protection requires `PR Gate`, `CodeQL`, `Lint PR
# title`). `raes-adapters` is a single package with optional per-simulator
# extras, so there is no per-adapter matrix and the test suite is one shard.
# Each job runs exactly one `nox` session and is reproduced locally with the
# command it runs; see docs/maintainers/ci.md for the fast lane, the full gate,
# job ownership, and local reproduction.
jobs:
# One distribution, one verification graph: hygiene, lint, governance policy,
# tool tests, typecheck, tests, the build/clean-install identity proof, and the
# strict docs build. `raes-adapters` is a single package with optional
# per-simulator extras, so there is no per-adapter matrix.
verify:
fast-checks:
name: Fast checks (hygiene + lint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
fetch-depth: 0
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Hygiene
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s hygiene
- name: Lint
# Report lint even when hygiene fails, so one fast-lane failure does not
# hide the other.
if: ${{ !cancelled() }}
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s lint

policy:
name: Policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Resolve requirement UID from branch
id: requirement
env:
Expand All @@ -39,15 +68,60 @@ jobs:
REQ_UID="$(printf '%s\n' "$BRANCH" | grep -oE '[A-Z]{3}-[0-9]{3}' | head -n1 || true)"
echo "Resolved branch='$BRANCH' uid='$REQ_UID'"
echo "uid=$REQ_UID" >> "$GITHUB_OUTPUT"
- name: Canonical verification graph
- name: Policy gates
run: |
policy_args=()
if [ -n "${{ steps.requirement.outputs.uid }}" ]; then
policy_args+=(--requirement-uid "${{ steps.requirement.outputs.uid }}")
else
policy_args+=(--skip-requirement)
fi
uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s verify -- "${policy_args[@]}"
uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s policy -- "${policy_args[@]}"

tool-tests:
name: Tool tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Tool tests
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s tool-tests

typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Typecheck
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s typecheck

tests:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Tests
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s tests
- name: Upload coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand All @@ -56,10 +130,42 @@ jobs:
path: coverage.xml
if-no-files-found: ignore

distributions:
name: Distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Build, clean-install, prove identity
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s distributions

docs:
name: Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
with:
enable-cache: true
- name: Strict docs build
run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s docs

sonar:
name: SonarCloud
runs-on: ubuntu-latest
needs: [verify]
# Static analysis needs only source + coverage, so it waits on `tests`
# alone rather than the whole verification graph.
needs: [tests]
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
Expand All @@ -76,26 +182,40 @@ jobs:

pr-gate:
name: PR Gate
needs: [verify, sonar]
# GATE CONTRACT: this is the single required status check that stands in for
# the whole verification graph. Every verification job MUST be listed in
# `needs` below — the assertion enforces exactly this list, so an unlisted
# job is an unenforced job. Add a job here whenever you add a job above.
needs: [fast-checks, policy, tool-tests, typecheck, tests, distributions, docs, sonar]
if: ${{ always() && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Assert required PR jobs passed
- name: Assert required jobs passed
env:
VERIFY_RESULT: ${{ needs.verify.result }}
NEEDS_JSON: ${{ toJSON(needs) }}
SAME_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
SONAR_RESULT: ${{ needs.sonar.result }}
run: |
if [ "$VERIFY_RESULT" != "success" ]; then
echo "::error::Verify did not succeed ($VERIFY_RESULT)"
exit 1
fi
if [ "$SAME_REPOSITORY" = "true" ] && [ "$SONAR_RESULT" != "success" ]; then
echo "::error::SonarCloud did not succeed for a same-repository PR ($SONAR_RESULT)"
set -euo pipefail
echo "needs: $NEEDS_JSON"
# Every verification job (all needs except `sonar`) must succeed.
if ! jq -e '
to_entries
| map(select(.key != "sonar"))
| (length > 0) and all(.[]; .value.result == "success")
' <<<"$NEEDS_JSON" >/dev/null; then
echo "::error::A required verification job did not succeed"
exit 1
fi
if [ "$SAME_REPOSITORY" != "true" ] && [ "$SONAR_RESULT" != "skipped" ]; then
echo "::error::SonarCloud must be skipped for a fork PR ($SONAR_RESULT)"
# SonarCloud must pass on same-repository PRs and must be skipped on
# fork PRs (no SONAR_TOKEN is exposed to forks).
sonar_result="$(jq -r '.sonar.result' <<<"$NEEDS_JSON")"
if [ "$SAME_REPOSITORY" = "true" ]; then
if [ "$sonar_result" != "success" ]; then
echo "::error::SonarCloud did not succeed for a same-repository PR ($sonar_result)"
exit 1
fi
elif [ "$sonar_result" != "skipped" ]; then
echo "::error::SonarCloud must be skipped for a fork PR ($sonar_result)"
exit 1
fi
echo "Required PR jobs passed"
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ governance, repo policy, ADR pins), `lint` (ruff), `typecheck` (mypy),
`tests` (pytest + coverage, base plus all extras), and `distributions` (build
the wheel/sdist and prove it clean-installs).

CI runs those stages as **independent, concurrent jobs** rather than one serial
job, and each job maps 1:1 to a `nox` session, so you can reproduce any red CI
job locally by running that one session (e.g. `nox -s lint`). See
[Continuous integration](docs/maintainers/ci.md) for the fast-feedback path,
the full merge gate, and per-job reproduction commands.

## Adding a simulator backend

1. Add a module under `src/raes_adapters/<simulator>/` (import
Expand Down
145 changes: 145 additions & 0 deletions docs/maintainers/ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Continuous integration

`raes-adapters` is one distribution verified by one graph
(`noxfile.py`). CI runs that graph's stages as **independent, concurrent
jobs** rather than one serial job, so a small change gets actionable
feedback in seconds and static analysis starts as soon as coverage exists —
without weakening the merge gate. This page explains the fast-feedback path,
the full gate, who owns each job, and how to reproduce any failure locally.

## The fast-feedback path

Every stage of the verification graph is its own job in
`.github/workflows/ci.yml`, and the jobs run at the same time:

| Job | `nox` session | What it checks |
| --- | --- | --- |
| **Fast checks (hygiene + lint)** | `hygiene`, `lint` | file hygiene (whitespace, EOL, YAML/JSON), `ruff format --check`, `ruff check` |
| **Policy** | `policy` | requirement governance, repo policy, ADR-immutability pins, project services, identity |
| **Tool tests** | `tool-tests` | stdlib unit tests for repository tooling under `tools/` |
| **Typecheck** | `typecheck` | `mypy` over `src`, base install plus each extra alone |
| **Tests** | `tests` | `pytest` + coverage, base plus each extra; uploads `coverage.xml` |
| **Distributions** | `distributions` | build wheel/sdist, clean-install, prove installed identity |
| **Docs** | `docs` | strict MkDocs build |

Because the jobs are independent, a formatting or lint mistake surfaces from
**Fast checks** in a few seconds without waiting on the typecheck, test, build,
or docs stages, and each stage reports its own result even when another stage
fails. Within **Fast checks**, lint still runs when hygiene fails (`if:
!cancelled()`) so one fast-lane failure never hides the other.

`SonarCloud` depends on `tests` **only** (`needs: [tests]`) because static
analysis needs source plus `coverage.xml` and nothing else — it no longer waits
for the whole graph (typecheck, build, docs) to finish before it starts.

## The full gate

Shortening the critical path does not remove any verification. Branch
protection on `main` and `dev` requires exactly three checks — `CodeQL`,
`Lint PR title`, and **`PR Gate`** — and `PR Gate` is the aggregator that
stands in for the entire verification graph.

`PR Gate` runs after every verification job plus `SonarCloud` (`if: always()`),
reads their results, and fails unless:

- **every** verification job succeeded (all `needs` except `sonar`), and
- `SonarCloud` **succeeded** on a same-repository PR, or was **skipped** on a
fork PR (forks never receive `SONAR_TOKEN`).

The `needs:` list on `PR Gate` is the gate contract: a verification job that is
not listed there is not enforced. **Adding a verification job means adding it to
that list.** Keeping one required check (`PR Gate`) means the parallel jobs can
be added, split, or renamed without reconfiguring branch protection, while the
protected branches still block on the complete graph.

## Job ownership and sharding

Each job maps 1:1 to a `nox` session, so ownership is unambiguous: the session
in `noxfile.py` is the single source of truth for what the job runs, and the
same session name is the local command (below).

The **test suite is a single shard.** `raes-adapters` is one package with
optional per-simulator extras; the `tests` session already runs the base
install and each extra in one job and aggregates their coverage with `coverage
combine`, so every test executes exactly once per run. Splitting into multiple
CI shards would add coordination (deterministic test partitioning, cross-shard
coverage merging) for a suite that finishes in seconds — the cost outweighs the
benefit at this size. If a future simulator's suite grows enough to justify it,
shard the `tests` session deterministically, keep `coverage combine` merging the
shard outputs, and record each shard's owner in the table above.

A **change-based early-feedback lane** (running only the checks a diff touches)
was evaluated and **not** adopted. The parallel jobs already deliver early
per-stage feedback, and the full graph is fast; a separate change-scoped lane
would add a second definition of "what to run" that can drift from the required
gate, for negligible time saved on a suite this small. The required merge gate
stays the complete graph.

## Reproduce any failure locally

Every CI job runs one `nox` session. Reproduce a red job by running that
session locally (needs [`uv`](https://docs.astral.sh/uv/), Python 3.12+):

```bash
# The whole graph, exactly as the sum of the CI jobs:
uv tool run --from 'nox[uv]==2026.4.10' nox -s verify

# A single failing job, by its session name:
uv tool run --from 'nox[uv]==2026.4.10' nox -s hygiene # Fast checks (hygiene)
uv tool run --from 'nox[uv]==2026.4.10' nox -s lint # Fast checks (lint)
uv tool run --from 'nox[uv]==2026.4.10' nox -s tool-tests # Tool tests
uv tool run --from 'nox[uv]==2026.4.10' nox -s typecheck # Typecheck
uv tool run --from 'nox[uv]==2026.4.10' nox -s tests # Tests (+ coverage.xml)
uv tool run --from 'nox[uv]==2026.4.10' nox -s distributions # Distributions
uv tool run --from 'nox[uv]==2026.4.10' nox -s docs # Docs

# The Policy job resolves a requirement UID from the branch; reproduce it with:
uv tool run --from 'nox[uv]==2026.4.10' nox -s policy -- --skip-requirement
# ...or, on a requirement branch, --requirement-uid REP-00X
```

`make verify`, `make precommit`, and `make prepush` wrap the same sessions for
the git hooks.

## Before / after timings

Measured from GitHub Actions job timings (`gh api .../actions/runs/<id>/jobs`)
on same-repository runs. *Final required-check completion* is the wall-clock
from the first verification job starting to `PR Gate` finishing; *first
actionable feedback* is the earliest verification job to report a result.

**Before — one serial `verify` job.** The whole graph ran in a single job
(~21–22 s), then `SonarCloud` (~51–53 s, dominated by the `sonar.qualitygate.wait`)
started only after that job finished, then `PR Gate` (~4 s):

- Final required-check completion: **≈ 82–85 s** (median ≈ 83 s over the
available same-repository runs of this design).
- First actionable feedback: only after a stage's turn inside the one serial
job — a lint error could wait behind hygiene and policy, and an earlier-stage
failure aborted the job before later stages ran at all.

> The single-`verify` design is recent (it arrived with the single-distribution
> cutover), so the sample is small; the values above are the observed spread
> rather than a large-sample percentile. Percentiles accrue in the Actions run
> history as more runs land on this workflow.

**After — the parallel graph.** Observed on this change's first parallel run
(Actions run `30407385220`): the seven verification jobs run concurrently and
all report by **+16 s** (Typecheck +10 s; Fast checks, Policy, Tool tests +15 s;
Tests, Docs +16 s). `SonarCloud` starts at **+18 s** — right after `tests`,
not after the whole graph — and its ~46 s quality-gate wait ends at +64 s;
`PR Gate` closes the run at **+74 s**.

- Final required-check completion: **74 s**, down from ≈ 82–85 s. SonarCloud's
quality-gate wait is the tall pole in *both* designs (it needs coverage and
cannot be parallelized away), so the remaining time is analysis the merge
gate requires rather than avoidable serialization.
- First actionable feedback: **≈ 10–16 s** — every stage reports on its own,
and a failure in one stage no longer hides the others. Under the old serial
job, stages ran one after another in a single ~22 s job and an early-stage
failure aborted the rest before they ran.

This first run also populated the `setup-uv` cache, so steady-state runs start
warm. The larger win is structural and grows with the repository: as simulators
are added, the typecheck/test/build stages that used to run one-after-another
now run side by side, and no single stage's failure hides the others.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ markdown_extensions:
nav:
- Home: index.md
- Maintainers:
- Continuous integration: maintainers/ci.md
- Project services: maintainers/project-services.md
- Decisions:
- Overview: decisions/adrs/README.md
Expand Down
Loading
Loading