diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3e4c9d8..38364e4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,8 +175,9 @@ jobs: wheels = sorted(pathlib.Path("dist").glob("*.whl")) # One wheel per platform and supported interpreter. The interpreters are the classifiers of - # pyproject.toml, which test-python-bindings.yml's matrix mirrors; the four platforms are - # the matrix platforms. Adding either to the matrix changes the expected count here. + # pyproject.toml, which the full matrix of scripts/select_matrix.py mirrors (the release runs the full + # matrix, not the small pull request one); the four platforms are the matrix platforms. Adding either to + # the matrix changes the expected count here. prefix = "Programming Language :: Python :: 3." classifiers = tomllib.load(open("pyproject.toml", "rb"))["project"]["classifiers"] pythons = [c.rsplit(" ", 1)[1] for c in classifiers if c.startswith(prefix)] diff --git a/.github/workflows/test-python-bindings.yml b/.github/workflows/test-python-bindings.yml index bf092cdb..bcffc56c 100644 --- a/.github/workflows/test-python-bindings.yml +++ b/.github/workflows/test-python-bindings.yml @@ -189,10 +189,25 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false outputs: arcadedb-tag: ${{ steps.jars.outputs.arcadedb-tag }} + matrix: ${{ steps.matrix.outputs.matrix }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The platform x Python legs of this run (scripts/select_matrix.py): a small matrix on + # pull requests, the full one on every other event. The organisation shares one runner + # cap with ArcadeData/arcadedb, so a pull request does not run every combination. + # Done first, so a failure here costs no Docker pull. + - name: Select the test matrix + id: matrix + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + matrix="$(python3 scripts/select_matrix.py bindings --event "$EVENT_NAME")" + echo "$matrix" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -250,30 +265,12 @@ jobs: strategy: # Don't cancel other matrix jobs if one fails fail-fast: false - matrix: - python-version: - - '3.10' - - '3.11' - - '3.12' - - '3.13' - - '3.14' - # Temporarily limit to four platforms (skip macOS x86_64, Windows ARM64) - # platform: ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64', 'windows/arm64'] - platform: ['linux/amd64', 'linux/arm64', 'darwin/arm64', 'windows/amd64'] - include: - - platform: linux/amd64 - runs-on: ubuntu-24.04 - - platform: linux/arm64 - runs-on: ubuntu-24.04-arm - - platform: darwin/arm64 - runs-on: macos-15 - - platform: windows/amd64 - runs-on: windows-2025 - # - platform: darwin/amd64 - # runs-on: macos-15-intel - # - platform: windows/arm64 - # runs-on: windows-11-arm - # macOS x86_64 and Windows ARM64 temporarily disabled + # Chosen by the "Select the test matrix" step of download-jars from the event that + # started the run (scripts/select_matrix.py holds the platform and Python lists): + # small on pull requests, the full 4 platforms x 5 Pythons on every other event. + # Every leg carries platform, runs-on and python-version, so the job names stay + # "Test arcadedb (, Python )". + matrix: ${{ fromJSON(needs.download-jars.outputs.matrix) }} steps: - name: Checkout code diff --git a/.github/workflows/test-python-examples.yml b/.github/workflows/test-python-examples.yml index 93d54b4e..234378ad 100644 --- a/.github/workflows/test-python-examples.yml +++ b/.github/workflows/test-python-examples.yml @@ -106,10 +106,25 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false outputs: arcadedb-tag: ${{ steps.jars.outputs.arcadedb-tag }} + matrix: ${{ steps.matrix.outputs.matrix }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The platform x Python legs of this run (scripts/select_matrix.py): a small matrix on + # pull requests, the full one on every other event. The organisation shares one runner + # cap with ArcadeData/arcadedb, so a pull request does not run every combination. + # Done first, so a failure here costs no Docker pull. + - name: Select the test matrix + id: matrix + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + matrix="$(python3 scripts/select_matrix.py examples --event "$EVENT_NAME")" + echo "$matrix" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -163,36 +178,18 @@ jobs: BUILD_VERSION: ${{ inputs.build-version }} # Pin the Hugging Face cache inside the workspace so it is cacheable with # one path on every platform (the default differs per OS). The examples - # embed with sentence-transformers, and this matrix is 20 jobs, so + # embed with sentence-transformers, and the full matrix is 20 jobs, so # uncached that is 20 independent model downloads per release and 20 # chances for a transient hub outage to fail a release. It has happened. HF_HOME: ${{ github.workspace }}/.hf-cache strategy: fail-fast: false - matrix: - python-version: - - '3.10' - - '3.11' - - '3.12' - - '3.13' - - '3.14' - # Temporarily limit to four platforms (skip macOS x86_64, Windows ARM64) - # platform: ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64', 'windows/arm64'] - platform: ['linux/amd64', 'linux/arm64', 'darwin/arm64', 'windows/amd64'] - include: - - platform: linux/amd64 - runs-on: ubuntu-24.04 - - platform: linux/arm64 - runs-on: ubuntu-24.04-arm - - platform: darwin/arm64 - runs-on: macos-15 - - platform: windows/amd64 - runs-on: windows-2025 - # - platform: darwin/amd64 - # runs-on: macos-15-intel - # - platform: windows/arm64 - # runs-on: windows-11-arm - # macOS x86_64 and Windows ARM64 temporarily disabled + # Chosen by the "Select the test matrix" step of download-jars from the event that + # started the run (scripts/select_matrix.py holds the platform and Python lists): + # small on pull requests, the full 4 platforms x 5 Pythons on every other event. + # Every leg carries platform, runs-on and python-version, so the job names stay + # "Test Python Examples (, Python )". + matrix: ${{ fromJSON(needs.download-jars.outputs.matrix) }} steps: - name: Checkout code diff --git a/CLAUDE.md b/CLAUDE.md index 2a7cfb75..2d82fbe8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,6 @@ python -m bandit -c pyproject.toml -r examples --severity-level medium --confide ## CI -`.github/workflows/test-python-bindings.yml` builds wheels for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 and tests Python 3.10–3.14; also runs nightly against `arcadedata/arcadedb:latest`. Both workflows accept an `arcadedb-tag` input to pin the image. `test-python-examples.yml` runs `examples/`. A dependency-floor audit resolves extras with `--resolution lowest-direct` for each Python classifier and runs pip-audit, so raise lower bounds when a floor has an advisory. +`.github/workflows/test-python-bindings.yml` builds wheels for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 and tests Python 3.10–3.14; also runs nightly against `arcadedata/arcadedb:latest`. The org shares a runner cap with upstream, so a pull request runs a small matrix (bindings: linux/amd64 on all five Pythons, the other three platforms on 3.10 and 3.14 = 11 legs; examples: all four platforms on 3.14 = 4 legs, 21 checks in all), while push to main, the nightly run, dispatch and the release run all 20 legs of each workflow (46 checks). `scripts/select_matrix.py` holds both lists (`PULL_REQUEST_LEGS`), the first job of each workflow runs it, and `tests/test_select_matrix.py` pins the full matrix; docs page `development/ci.adoc`. Keep the job names `Test arcadedb (, Python )` unchanged. Both workflows accept an `arcadedb-tag` input to pin the image. `test-python-examples.yml` runs `examples/`. A dependency-floor audit resolves extras with `--resolution lowest-direct` for each Python classifier and runs pip-audit, so raise lower bounds when a floor has an advisory. `.github/workflows/release.yml` publishes on a bare-version tag (`26.11.1.dev0`, `26.11.1`, `26.11.1.post1`; no `v`, never `aN`/`bN`/`rcN`). Its first job runs `scripts/check_version.py` (the devN/postN counting rule, read-only, fails closed); `scripts/after_tag_verify.sh` checks the finished release. The rule is stated once, on the docs page `development/release.adoc`. diff --git a/README.md b/README.md index ec1f9b65..5c37bccd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ pip install arcadedb # or: uv add arcadedb This package was published as arcadedb-embedded until 26.10.x; install arcadedb now. -- Python 3.10 to 3.14. CI builds and tests all five versions on every platform below. +- Python 3.10 to 3.14. CI builds and tests all five versions on every platform below for `main`, every night and each release. A pull request runs a smaller selection. - Linux x86_64 and ARM64, macOS on Apple Silicon, Windows x86_64. One wheel per platform and Python version. - Import name: `arcadedb`. Optional extras: `arrow`, `pandas`, `vector`. diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index dff0f2fa..a4014eeb 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -6,4 +6,5 @@ ** xref:how-to/databases-and-queries.adoc[Databases and queries] ** xref:how-to/concurrency.adoc[Concurrency, processes and crashes] * Development +** xref:development/ci.adoc[Continuous integration] ** xref:development/release.adoc[Release] diff --git a/docs/modules/ROOT/pages/development/ci.adoc b/docs/modules/ROOT/pages/development/ci.adoc new file mode 100644 index 00000000..3c39f855 --- /dev/null +++ b/docs/modules/ROOT/pages/development/ci.adoc @@ -0,0 +1,39 @@ += Continuous integration +:description: Which platforms and Python versions the CI runs for a pull request, and which for main, the nightly run and a release. + +Two workflows test the package: `test-python-bindings.yml` builds a wheel and runs the unit tests, and `test-python-examples.yml` runs the scripts in `examples/`. Both build the wheels on a native runner of each platform. + +The package supports four platforms and five Python versions (3.10 to 3.14). That is 20 combinations for each workflow. A pull request does not run all of them, because the organisation shares one runner limit with ArcadeData/arcadedb. Each macOS or Windows job on a pull request delays the upstream jobs too. + +== What runs when + +The script `scripts/select_matrix.py` chooses the combinations. The first job of each workflow, `Download ArcadeDB JARs`, runs it and passes the result to the test job. + +[cols="2,3,3"] +|=== +| Event | Unit tests (`Test arcadedb`) | Examples (`Test Python Examples`) + +| Pull request +| `linux/amd64` on all five Python versions. `linux/arm64`, `darwin/arm64` and `windows/amd64` on Python 3.10 and 3.14. +| All four platforms on Python 3.14. + +| Push to `main`, the nightly run, a manual run, a release +| All 20 combinations. +| All 20 combinations. +|=== + +A pull request runs 21 checks: 15 test jobs (11 and 4), the two `Download ArcadeDB JARs` jobs, the two summary jobs, `Bandit security scan` and `Dependency floor audit`. The full matrix runs 46. + +The names of the test jobs do not change: `Test arcadedb (, Python )`. A check keeps its history when a pull request runs fewer of them. + +A release always runs the full matrix, because `release.yml` expects one wheel for each platform and Python version. The nightly run also tests against the newest `arcadedata/arcadedb:latest` image. + +== Other rules + +* A pull request that changes only documentation (`docs/**`, `*.adoc`, `CLAUDE.md`, `examples/README.md`, `LICENSE*`) runs nothing. +* A draft pull request skips the platform jobs. Marking it ready for review starts them. +* A new push to a pull request cancels the run it replaces. A run on `main` is never cancelled. + +== Change the selection + +Edit `PULL_REQUEST_LEGS` in `scripts/select_matrix.py`. The test `tests/test_select_matrix.py` fixes the full matrix at 20 combinations, so a change that shrinks it fails. When you add a Python version, add it to `PYTHONS` in the script and to the classifiers in `pyproject.toml`. A test checks that both lists are the same. diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index aa89c6c2..add9c4cf 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -87,7 +87,7 @@ Both packages import as `{import-name}`, so an environment holds one of them. Th == Requirements -* Python 3.10 to 3.14. The wheels' CI runs all five versions on every supported platform. +* Python 3.10 to 3.14. The wheels' CI runs all five versions on every supported platform for `main`, the nightly run and each release. A pull request runs a smaller selection (xref:development/ci.adoc[Continuous integration]). * Linux (x86_64 and ARM64), macOS on Apple Silicon, or Windows (x86_64). * No Java installation. diff --git a/docs/modules/ROOT/pages/installation.adoc b/docs/modules/ROOT/pages/installation.adoc index 2f35eeeb..8827d405 100644 --- a/docs/modules/ROOT/pages/installation.adoc +++ b/docs/modules/ROOT/pages/installation.adoc @@ -24,7 +24,7 @@ The package is self-contained. It carries a Java 25 runtime for its platform, so == Requirements -* Python 3.10 to 3.14. The wheels' CI runs all five versions on every supported platform. +* Python 3.10 to 3.14. The wheels' CI runs all five versions on every supported platform for `main`, the nightly run and each release. A pull request runs a smaller selection (xref:development/ci.adoc[Continuous integration]). * One of the four platforms below. Every wheel is platform-specific, built on a native runner of its platform, and carries a JRE for that platform, so pip needs no hint to pick the right one. diff --git a/scripts/select_matrix.py b/scripts/select_matrix.py new file mode 100644 index 00000000..6f03d498 --- /dev/null +++ b/scripts/select_matrix.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Pick the platform x Python legs a CI workflow runs for the event that started it. + +The wheels support four platforms and five Python versions, which is 20 legs per workflow. A +pull request does not need all of them: the organisation shares one runner cap with +ArcadeData/arcadedb, so every extra macOS or Windows leg on a pull request delays upstream too. + +- `pull_request` runs the small matrix below. +- Every other event runs the full matrix: a push to main, the nightly schedule, a manual + dispatch, and the release workflow (which calls the workflows as `workflow_call`; the event + name seen there is the caller's, a tag push). The release needs one wheel per platform and + Python version, and `release.yml` counts them. + +The selection fails open to the full matrix: an event name this script does not know runs +everything, never less. + +usage: + select_matrix.py bindings [--event NAME] # test-python-bindings.yml + select_matrix.py examples [--event NAME] # test-python-examples.yml + +`--event` defaults to $GITHUB_EVENT_NAME. The output is one line of JSON for +`strategy.matrix`: {"include": [{"platform": ..., "runs-on": ..., "python-version": ...}, ...]}. +The names of the jobs stay "Test arcadedb (, Python )", so the history of a +check is comparable before and after the selection. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +# Temporarily limited to four platforms: macOS x86_64 (runner macos-15-intel) and Windows ARM64 +# (windows-11-arm) are off. This is the platform column both workflows had before this script. +RUNNERS = { + "linux/amd64": "ubuntu-24.04", + "linux/arm64": "ubuntu-24.04-arm", + "darwin/arm64": "macos-15", + "windows/amd64": "windows-2025", +} + +# Every Python the package supports. tests/test_select_matrix.py compares this with the +# classifiers of pyproject.toml, so adding a version there without adding it here fails. +PYTHONS = ("3.10", "3.11", "3.12", "3.13", "3.14") +OLDEST = PYTHONS[0] +NEWEST = PYTHONS[-1] + +# The legs of a pull request, per workflow: platform -> Python versions. +# +# bindings: all Pythons on linux/amd64 (the cheap, fast runner and the one most contributors +# use), the oldest and the newest on the three other platforms. A bug that depends on the +# interpreter shows on the ends of the range, and one that depends on the platform shows on +# any version of it. +# +# examples: the newest Python only, on every platform. The examples are slow (torch, +# sentence-transformers), and they carry platform-specific code (`os.name == "nt"` branches in +# examples 07 to 12) and per-platform steps in the workflow (a CPU torch index on macOS, UTF-8 +# on Windows), so all four platforms stay. +PULL_REQUEST_LEGS = { + "bindings": { + "linux/amd64": PYTHONS, + "linux/arm64": (OLDEST, NEWEST), + "darwin/arm64": (OLDEST, NEWEST), + "windows/amd64": (OLDEST, NEWEST), + }, + "examples": {platform: (NEWEST,) for platform in RUNNERS}, +} + + +def _legs(per_platform: dict[str, tuple[str, ...]]) -> list[dict[str, str]]: + return [ + {"platform": platform, "runs-on": RUNNERS[platform], "python-version": python} + for platform in RUNNERS + for python in per_platform.get(platform, ()) + ] + + +def select(workflow: str, event: str) -> dict[str, list[dict[str, str]]]: + """The `strategy.matrix` of `workflow` ("bindings" or "examples") for `event`.""" + if workflow not in PULL_REQUEST_LEGS: + raise ValueError( + f"unknown workflow {workflow!r}; use one of {sorted(PULL_REQUEST_LEGS)}" + ) + if event == "pull_request": + return {"include": _legs(PULL_REQUEST_LEGS[workflow])} + return {"include": _legs({platform: PYTHONS for platform in RUNNERS})} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("workflow", choices=sorted(PULL_REQUEST_LEGS)) + parser.add_argument( + "--event", + default=os.environ.get("GITHUB_EVENT_NAME", ""), + help="the event that started the workflow (default: $GITHUB_EVENT_NAME)", + ) + args = parser.parse_args(argv) + print(json.dumps(select(args.workflow, args.event), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_select_matrix.py b/tests/test_select_matrix.py new file mode 100644 index 00000000..618d79bb --- /dev/null +++ b/tests/test_select_matrix.py @@ -0,0 +1,125 @@ +"""Which platform x Python legs CI runs per event: a small matrix on pull requests, the full one everywhere else.""" + +from __future__ import annotations + +import importlib.util +import itertools +import json +import re +import subprocess # nosec B404 - test-controlled child processes, no untrusted input +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "scripts" / "select_matrix.py" +WORKFLOWS = { + "bindings": ROOT / ".github" / "workflows" / "test-python-bindings.yml", + "examples": ROOT / ".github" / "workflows" / "test-python-examples.yml", +} + +# The matrix both workflows had before the selection existed: 4 platforms x 5 Pythons. Written +# out here on purpose, not imported from the script, so the full matrix cannot shrink unnoticed. +PLATFORMS = { + "linux/amd64": "ubuntu-24.04", + "linux/arm64": "ubuntu-24.04-arm", + "darwin/arm64": "macos-15", + "windows/amd64": "windows-2025", +} +PYTHONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] +FULL = {(p, v) for p, v in itertools.product(PLATFORMS, PYTHONS)} +NON_PULL_REQUEST_EVENTS = [ + "push", + "schedule", + "workflow_dispatch", + "workflow_call", + "", + "some_new_event", +] + + +def _load(): + spec = importlib.util.spec_from_file_location("select_matrix", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules["select_matrix"] = module + spec.loader.exec_module(module) + return module + + +sm = _load() + + +def _pairs(matrix): + legs = matrix["include"] + pairs = [(leg["platform"], leg["python-version"]) for leg in legs] + assert len(pairs) == len(set(pairs)), "a leg is listed twice" + for leg in legs: + assert set(leg) == {"platform", "runs-on", "python-version"} + assert leg["runs-on"] == PLATFORMS[leg["platform"]] + return set(pairs) + + +@pytest.mark.parametrize("workflow", ["bindings", "examples"]) +@pytest.mark.parametrize("event", NON_PULL_REQUEST_EVENTS) +def test_every_event_but_pull_request_runs_the_full_matrix(workflow, event): + matrix = sm.select(workflow, event) + assert len(matrix["include"]) == 20 + assert _pairs(matrix) == FULL + + +def test_pull_request_bindings_matrix(): + pairs = _pairs(sm.select("bindings", "pull_request")) + assert {v for p, v in pairs if p == "linux/amd64"} == set(PYTHONS) + for platform in ("linux/arm64", "darwin/arm64", "windows/amd64"): + assert {v for p, v in pairs if p == platform} == {"3.10", "3.14"}, platform + assert len(pairs) == 11 + + +def test_pull_request_examples_matrix(): + pairs = _pairs(sm.select("examples", "pull_request")) + assert pairs == {(p, "3.14") for p in PLATFORMS} + + +@pytest.mark.parametrize("workflow", ["bindings", "examples"]) +def test_pull_request_matrix_is_part_of_the_full_matrix(workflow): + assert _pairs(sm.select(workflow, "pull_request")) < FULL + + +def test_unknown_workflow_is_refused(): + with pytest.raises(ValueError): + sm.select("docs", "pull_request") + + +def test_pythons_match_the_classifiers_of_pyproject(): + # release.yml expects one wheel per classifier Python and platform from the full matrix; a + # Python added to one place and not the other would fail the release, not a pull request. + # A regex, not tomllib: this test also runs on Python 3.10, which has no tomllib. + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + classifiers = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) + assert classifiers, "no Python version classifiers found in pyproject.toml" + assert list(sm.PYTHONS) == classifiers + + +def test_command_line_prints_one_line_of_matrix_json(): + for event, count in (("pull_request", 11), ("push", 20)): + out = subprocess.run( # nosec B603 - fixed argument list, no untrusted input + [sys.executable, "-I", str(SCRIPT_PATH), "bindings", "--event", event], + capture_output=True, + text=True, + check=True, + ).stdout + assert out.count("\n") == 1 + assert len(json.loads(out)["include"]) == count + + +@pytest.mark.parametrize("workflow", sorted(WORKFLOWS)) +def test_workflow_takes_its_matrix_from_the_script(workflow): + text = WORKFLOWS[workflow].read_text(encoding="utf-8") + assert f"scripts/select_matrix.py {workflow}" in text + assert "matrix: ${{ fromJSON(needs.download-jars.outputs.matrix) }}" in text + # the lists are in the script; a copy left in the workflow would say one thing and run another + assert ( + "python-version:\n" not in text.split("strategy:", 1)[1].split("steps:", 1)[0] + )