diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37c4e4f..447cbe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -39,7 +68,7 @@ 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 @@ -47,7 +76,52 @@ jobs: 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 @@ -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 @@ -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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07a2156..1ee2f74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//` (import diff --git a/docs/maintainers/ci.md b/docs/maintainers/ci.md new file mode 100644 index 0000000..5e7ad51 --- /dev/null +++ b/docs/maintainers/ci.md @@ -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//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. diff --git a/mkdocs.yml b/mkdocs.yml index 1e0bdf8..7dbcc49 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/tools/check_project_services.py b/tools/check_project_services.py index 784e48d..468236f 100644 --- a/tools/check_project_services.py +++ b/tools/check_project_services.py @@ -144,11 +144,18 @@ def validate_repository(repo_root: Path) -> list[str]: _require(title_lint, "name: Lint PR title", ".github/workflows/pr-title-lint.yml", errors) ci = _read_required(repo_root, ".github/workflows/ci.yml", errors) + # The verification graph runs as independent parallel jobs; `PR Gate` is the + # single aggregating required check that keeps every stage mandatory before a + # protected-branch merge. Pin its contract so no job can silently leave the + # gate: it must depend on every verification job plus Sonar, run on every PR, + # and fail closed unless each verification job succeeded (and Sonar passed on + # same-repository PRs). Adding a verification job means extending this list. for expected in ( "name: PR Gate", - "needs: [verify, sonar]", + "needs: [fast-checks, policy, tool-tests, typecheck, tests, distributions, docs, sonar]", "if: ${{ always() && github.event_name == 'pull_request' }}", - "Verify did not succeed", + '.value.result == "success"', + "A required verification job did not succeed", "SonarCloud did not succeed for a same-repository PR", ): _require(ci, expected, ".github/workflows/ci.yml", errors)