diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..59a0cbf --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,551 @@ +name: Compatibility + +# Runs the backend and frontend checks on GitHub-hosted runners other than the +# checked lane, and records each runner with the compatibility model in +# docs/CI-MATRIX.md: Linux, Windows and macOS on x86-64 and Arm64, an x64 +# Python emulated on Windows on Arm, and UBI 9 and 10 in rootless Podman. +# A green job is evidence about one runner image and commit. It changes no +# support status in docs/DEPENDENCY-STRATEGY.md and is not a required check. + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/compatibility.yml' + - '.nvmrc' + - 'backend/**' + - 'frontend/**' + - 'scripts/ci/**' + - 'tests/compatibility/**' + - 'docs/CI-MATRIX.md' + - 'docs/DEPENDENCY-STRATEGY.md' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A newer run for the same pull request cancels the older one; a manual dispatch gets its own group. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + platform: + # The same steps on every lane. The install step reports blocked, and passes, only when the + # reports explain the failure: a wheel the lock lacks for this platform, which the installer + # names, or a platform requirement the lock does not pin. Any other failure fails the job. + # The Windows lanes run the same steps in platform-windows below. + name: Platform / ${{ matrix.lane }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + - lane: linux-x64 + runner: ubuntu-24.04 + python: cpython-3.12.15-linux-x86_64-gnu + node-arch: x64 + frontend: true + - lane: linux-arm64 + runner: ubuntu-24.04-arm + python: cpython-3.12.15-linux-aarch64-gnu + node-arch: arm64 + frontend: true + - lane: macos-arm64 + runner: macos-15 + python: cpython-3.12.15-macos-aarch64-none + node-arch: arm64 + frontend: true + - lane: macos-x64 + runner: macos-15-intel + python: cpython-3.12.15-macos-x86_64-none + node-arch: x64 + frontend: true + env: &platform_env + # uv ignores uv.toml and pyproject.toml files, which a pull request could add. + UV_NO_CONFIG: '1' + LOCK: backend/locks/cp312-linux-x86_64.txt + TEST_LOCK: backend/locks/cp312-linux-x86_64-test.txt + PROFILE: gha-${{ matrix.lane }}-cp312 + PYTHON_REQUEST: ${{ matrix.python }} + # platform-windows runs these steps too (a YAML alias); they read GITHUB_JOB where the two jobs differ. + steps: &platform_steps + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.12.24' + enable-cache: false + # With a Python version given, setup-uv does not look for an interpreter in the checkout and run it. + python-version: ${{ matrix.python }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + architecture: ${{ matrix.node-arch }} + package-manager-cache: false + - name: Create a CPython 3.12.15 environment + run: | + # The Windows runners' administrator token is allowed only in platform-windows, under its condition and exception. + if [ "$RUNNER_OS" = Windows ] && [ "$GITHUB_JOB" != platform-windows ]; then echo "::error::Windows lanes run only in the platform-windows job"; exit 1; fi + mkdir -p "$RUNNER_TEMP/compatibility" + echo "RECORDS=$RUNNER_TEMP/compatibility" >> "$GITHUB_ENV" + uv venv --managed-python --python "$PYTHON_REQUEST" + if [ "$RUNNER_OS" = Windows ]; then venv_python="$GITHUB_WORKSPACE/.venv/Scripts/python.exe"; else venv_python="$GITHUB_WORKSPACE/.venv/bin/python"; fi + echo "VENV_PYTHON=$venv_python" >> "$GITHUB_ENV" + "$venv_python" -VV + "$venv_python" -c 'import platform, sqlite3, sysconfig; print(platform.machine(), sysconfig.get_platform(), "SQLite", sqlite3.sqlite_version)' + test "$("$venv_python" -c 'import platform; print(platform.python_version())')" = 3.12.15 + - name: Check the lock's wheels for this interpreter + # packaging, from the lock, reads the interpreter's wheel tags; the probe reads PyPI's JSON API for each pinned version. + run: | + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py pin --lock "$LOCK" --name packaging --output "$RUNNER_TEMP/packaging.txt" + uv pip install --python "$VENV_PYTHON" --require-hashes --only-binary :all: --no-deps -r "$RUNNER_TEMP/packaging.txt" + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py wheels --lock "$LOCK" --output "$RECORDS/wheels.json" --summary "$GITHUB_STEP_SUMMARY" + - name: Install the hashed backend lock + id: install + run: | + sync=0 + uv pip sync --python "$VENV_PYTHON" --require-hashes --only-binary :all: "$LOCK" 2>&1 | tee "$RECORDS/install.log" || sync=$? + check=skip + if [ "$sync" = 0 ]; then + check=0 + uv pip check --python "$VENV_PYTHON" 2>&1 | tee -a "$RECORDS/install.log" || check=$? + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py requirements --lock "$LOCK" --output "$RECORDS/requirements.json" --summary "$GITHUB_STEP_SUMMARY" + fi + # Exits 1 when the stage failed; blocked and passed set the status and exit 0. + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py install-stage --sync "$sync" --check "$check" --log "$RECORDS/install.log" \ + --wheels "$RECORDS/wheels.json" --requirements "$RECORDS/requirements.json" --github-output "$GITHUB_OUTPUT" + - name: Django check and migration drift + id: initialize + if: ${{ steps.install.outputs.status == 'passed' }} + working-directory: backend + run: | + "$VENV_PYTHON" manage.py check + "$VENV_PYTHON" manage.py makemigrations --check --dry-run + - name: Django tests + id: execute + if: ${{ steps.initialize.outcome == 'success' }} + working-directory: backend + run: | + "$VENV_PYTHON" manage.py test core agent 2>&1 | tee "$RECORDS/django-tests.log" + # Django exits 0 when no test is collected; require a nonzero count. + grep -Eq '^Ran [1-9][0-9]* tests? in ' "$RECORDS/django-tests.log" || { echo '::error::Django ran no tests'; exit 1; } + - name: pytest from the hashed test lock + id: correctness + if: ${{ steps.execute.outcome == 'success' }} + run: | + uv pip sync --python "$VENV_PYTHON" --require-hashes --only-binary :all: "$TEST_LOCK" + uv pip check --python "$VENV_PYTHON" + cd backend + "$VENV_PYTHON" ../scripts/ci/check_test_parity.py + # pytest exits 5 when it collects no test, so an empty run fails. + "$VENV_PYTHON" -m pytest + - name: Install npm 11.21.0 and the locked frontend dependencies + id: frontend-install + if: ${{ !cancelled() && matrix.frontend }} + working-directory: frontend + run: | + npm install --global npm@11.21.0 + node -p 'process.version + " " + process.arch' + test "$(npm --version)" = 11.21.0 + npm ci + - name: Frontend lint, type check, build, and unit tests + id: frontend + if: ${{ !cancelled() && matrix.frontend }} + working-directory: frontend + run: | + npm run lint + npx next typegen + npx tsc --noEmit + npm run build + npm run test:unit + - name: Record this runner's environment + # The collector records the stages from the steps above: an outcome of success is passed, failure is + # failed, and a step skipped because an earlier one did not pass is blocked. Its docstring lists every + # source it reads. Never escalates privileges. + id: collect + if: ${{ !cancelled() }} + env: + INSTALL_OUTCOME: ${{ steps.install.outcome }} + INSTALL_STATUS: ${{ steps.install.outputs.status }} + INSTALL_REASON: ${{ steps.install.outputs.reason }} + INITIALIZE: ${{ steps.initialize.outcome }} + EXECUTE: ${{ steps.execute.outcome }} + CORRECTNESS: ${{ steps.correctness.outcome }} + FRONTEND_ENABLED: ${{ matrix.frontend }} + FRONTEND_INSTALL: ${{ steps.frontend-install.outcome }} + FRONTEND: ${{ steps.frontend.outcome }} + run: | + args=() + job="compatibility.yml#$GITHUB_JOB" + ran() { # stage, status, log reference, reason + args+=(--stage "$1=$2" --stage-evidence "$1=log:$3" --stage-escalation "$1=none") + if [ -n "$4" ]; then args+=(--stage-reason "$1=$4"); fi + } + step() { # stage, outcome, step name, the stage it needs + case "$2" in + success) ran "$1" passed "$job/$3" "" ;; + failure) ran "$1" failed "$job/$3" "the step failed; see its log" ;; + skipped) args+=(--stage "$1=blocked" --stage-reason "$1=$4 did not pass") ;; + *) args+=(--stage "$1=not-run" --stage-reason "$1=the step did not run") ;; + esac + } + case "$INSTALL_STATUS" in + passed|failed) ran install "$INSTALL_STATUS" "$job/Install the hashed backend lock" "$INSTALL_REASON" ;; + blocked) args+=(--stage install=blocked --stage-reason "install=$INSTALL_REASON" --stage-evidence "install=log:$job/Install the hashed backend lock") ;; + *) + if [ "$INSTALL_OUTCOME" = failure ]; then + ran install failed "$job/Install the hashed backend lock" "the install step failed before it set a status" + else + args+=(--stage install=blocked --stage-reason "install=the install step did not run because an earlier step failed") + fi ;; + esac + step initialize "$INITIALIZE" "Django check and migration drift" install + step execute "$EXECUTE" "Django tests" initialize + step correctness "$CORRECTNESS" "pytest from the hashed test lock" execute + security="no security check runs on this lane" + if [ "$GITHUB_JOB" = platform-windows ] && [ "$RUNNER_OS" = Windows ]; then + # The temporary Windows administrator-token exception (docs/SECURITY-ARCHITECTURE.md): the record itself + # says that the policy is skipped and security is not verified, so it never reads as a non-administrator result. + security="not verified: no security check runs on this lane, and the privilege policy is skipped under the temporary Windows administrator-token exception" + printf '%s\n' '{"policy": "skipped", "security": "not-verified", "compatibility": "incomplete", "non_administrator_run": "not-run", "reason": "GitHub-hosted Windows runners give the job user an elevated administrator token only", "exception": "docs/SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token"}' > "$RECORDS/policy-exception.json" + args+=(--extension "x-policy-exception=$RECORDS/policy-exception.json") + fi + args+=(--stage security=not-run --stage-reason "security=$security") + args+=(--stage performance=not-run --stage-reason "performance=not measured on shared runners") + for name in wheels requirements; do + if [ -f "$RECORDS/$name.json" ]; then args+=(--extension "x-$name=$RECORDS/$name.json"); fi + done + # The stages describe the backend; the frontend steps' outcomes go in x-frontend, by the same mapping. + if [ "$FRONTEND_ENABLED" = true ]; then frontend_reason="the step did not run"; else frontend_reason="this lane runs no frontend step"; fi + frontend() { # outcome, step name + case "$1" in + success) printf '{"status": "passed", "evidence": ["log:%s/%s"]}' "$job" "$2" ;; + failure) printf '{"status": "failed", "evidence": ["log:%s/%s"]}' "$job" "$2" ;; + *) printf '{"status": "not-run", "reason": "%s"}' "$frontend_reason" ;; + esac + } + printf '{"install": %s, "checks": %s}\n' "$(frontend "$FRONTEND_INSTALL" "Install npm 11.21.0 and the locked frontend dependencies")" \ + "$(frontend "$FRONTEND" "Frontend lint, type check, build, and unit tests")" > "$RECORDS/frontend.json" + args+=(--extension "x-frontend=$RECORDS/frontend.json") + "$VENV_PYTHON" -I scripts/ci/collect_environment.py --profile-id "$PROFILE" --output "$RECORDS/$PROFILE.result.json" \ + --summary "$GITHUB_STEP_SUMMARY" "${args[@]}" + - name: Check the record and the committed profiles + if: ${{ !cancelled() && steps.collect.outcome != 'skipped' }} + run: | + policy=() + if [ "$GITHUB_JOB" = platform-windows ] && [ "$RUNNER_OS" = Windows ]; then + # Only here, under the temporary Windows administrator-token exception (docs/SECURITY-ARCHITECTURE.md): the + # job's user is an administrator, so the record cannot pass app-non-root. The validator reports policy-skipped. + policy=(--no-policy) + echo "::warning title=Privilege policy skipped::Windows administrator token: policy skipped, security not verified, compatibility incomplete. This is not a non-administrator result." + printf '%s\n' "### Windows administrator-token exception" "" \ + "- Privilege policy: skipped (\`--no-policy\`); the validator reports \`policy-skipped\`." \ + "- Security: not verified. Compatibility: incomplete." \ + "- Not a non-administrator result. See docs/SECURITY-ARCHITECTURE.md, \"Temporary exception: Windows administrator token\"." "" >> "$GITHUB_STEP_SUMMARY" + fi + "$VENV_PYTHON" -I scripts/ci/validate_compatibility.py --require-real --dependency-strategy docs/DEPENDENCY-STRATEGY.md "${policy[@]}" \ + --profile "tests/compatibility/profiles/$PROFILE.json" --summary "$GITHUB_STEP_SUMMARY" \ + "$RECORDS/$PROFILE.result.json" tests/compatibility/profiles/*.json + - name: Test the probes and the validator + # The tests describe made-up machines; they are written for Linux and run on the Linux lanes. + if: ${{ !cancelled() && runner.os == 'Linux' && steps.collect.outcome != 'skipped' }} + run: | + "$VENV_PYTHON" -I scripts/ci/tests/test_compatibility_schema.py + "$VENV_PYTHON" -I scripts/ci/tests/test_environment_probe.py + "$VENV_PYTHON" -I scripts/ci/tests/test_python_abi_probe.py + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: ${{ !cancelled() }} + with: + name: compatibility-${{ matrix.lane }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/compatibility/ + if-no-files-found: warn + # The record and the reports are the lane's only machine-readable evidence: keep them 90 days, GitHub's default for logs. + retention-days: 90 + + platform-windows: + # The platform steps on GitHub-hosted Windows runners, whose job user holds an elevated administrator token + # only. They run under the temporary exception in docs/SECURITY-ARCHITECTURE.md ("Temporary exception: Windows + # administrator token"): for a pull request from a branch of this repository, and for a manual run on main, + # which needs write access to start. A fork's pull request skips this job: an intentional not-run, never a pass. + # A job's if is evaluated before its matrix is expanded, so it reads only the github context. + if: >- + ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} + name: Platform / ${{ matrix.lane }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + - lane: windows-x64 + runner: windows-2025 + python: cpython-3.12.15-windows-x86_64-none + node-arch: x64 + frontend: true + - lane: windows-arm64 + runner: windows-11-arm + python: cpython-3.12.15-windows-aarch64-none + node-arch: arm64 + frontend: true + - lane: windows-arm64-x64 + # An x64 Python and Node on Windows on Arm, which Windows runs through its x64 emulation. + runner: windows-11-arm + python: cpython-3.12.15-windows-x86_64-none + node-arch: x64 + frontend: false + env: *platform_env + steps: *platform_steps + + ubi: + # UBI 9 and 10 in rootless Podman on the Ubuntu runners: the container runs as the runner's own user + # (--userns=keep-id), with every capability dropped and no-new-privileges, and is neither privileged + # nor given a device. Without rootless Podman the job records the lane as blocked and fails; it never + # falls back to Docker or to a rootful engine. A UBI container pass is not a RHEL host pass: the kernel, + # SELinux mode and systemd are the Ubuntu VM's. + name: UBI / ${{ matrix.lane }} / rootless Podman + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: + # Each image is pulled by its multi-architecture index digest, which pins every per-architecture manifest; + # the tag only names the release. Selected 2026-10-12; see docs/DEPENDENCY-STRATEGY.md. + - lane: ubi9-x64 + runner: ubuntu-24.04 + image: registry.access.redhat.com/ubi9/ubi:9.8-1791765424@sha256:866c52a4f649b2e027e0eafeede5f37dd13ac58ab63bbe2950d7928334312a4c + arch: amd64 + python: cpython-3.12.15-linux-x86_64-gnu + - lane: ubi9-arm64 + runner: ubuntu-24.04-arm + image: registry.access.redhat.com/ubi9/ubi:9.8-1791765424@sha256:866c52a4f649b2e027e0eafeede5f37dd13ac58ab63bbe2950d7928334312a4c + arch: arm64 + python: cpython-3.12.15-linux-aarch64-gnu + - lane: ubi10-x64 + runner: ubuntu-24.04 + image: registry.access.redhat.com/ubi10/ubi:10.2-1791444044@sha256:27e14f4987d7abe56664d7e1b1dddcd0226d4ca593da5726f0f65697a17d0fee + arch: amd64 + python: cpython-3.12.15-linux-x86_64-gnu + - lane: ubi10-arm64 + runner: ubuntu-24.04-arm + image: registry.access.redhat.com/ubi10/ubi:10.2-1791444044@sha256:27e14f4987d7abe56664d7e1b1dddcd0226d4ca593da5726f0f65697a17d0fee + arch: arm64 + python: cpython-3.12.15-linux-aarch64-gnu + env: + UV_NO_CONFIG: '1' + LOCK: backend/locks/cp312-linux-x86_64.txt + TEST_LOCK: backend/locks/cp312-linux-x86_64-test.txt + PROFILE: gha-${{ matrix.lane }}-cp312 + PYTHON_REQUEST: ${{ matrix.python }} + IMAGE: ${{ matrix.image }} + IMAGE_ARCH: ${{ matrix.arch }} + CONTAINER: claus-ubi + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: '0.12.24' + enable-cache: false + python-version: ${{ matrix.python }} + - name: Create a host CPython 3.12.15 for the collector's engine step and the validator + # Kept outside the checkout, apart from the container's environment and from the runner's system Python. + run: | + mkdir -p "$RUNNER_TEMP/compatibility" + echo "RECORDS=$RUNNER_TEMP/compatibility" >> "$GITHUB_ENV" + uv venv --managed-python --python "$PYTHON_REQUEST" "$RUNNER_TEMP/host-venv" + echo "HOST_PYTHON=$RUNNER_TEMP/host-venv/bin/python" >> "$GITHUB_ENV" + echo "UV_DIR=$(dirname "$(command -v uv)")" >> "$GITHUB_ENV" + - name: Check that Podman runs rootless + id: podman + run: | + if ! command -v podman > /dev/null; then + echo 'reason=podman is not installed on this runner' >> "$GITHUB_OUTPUT" + echo '::error::podman is not installed on this runner' + exit 1 + fi + podman --version + rootless=$(podman info --format '{{.Host.Security.Rootless}}') || { echo 'reason=podman info failed for the runner user' >> "$GITHUB_OUTPUT"; exit 1; } + if [ "$rootless" != true ]; then + echo 'reason=podman does not run rootless for the runner user' >> "$GITHUB_OUTPUT" + echo '::error::podman does not run rootless for the runner user' + exit 1 + fi + podman info --format '{{.Host.OCIRuntime.Name}} {{.Host.OCIRuntime.Version}}; cgroups {{.Host.CgroupsVersion}}; network {{.Host.NetworkBackend}}' + - name: Record that the lane is blocked without rootless Podman + # The host's record, with no container layer, shows the runner's user and why the container stages did not run. + # It is not checked against the lane's profile, which declares the container; the job has already failed. + if: ${{ failure() && steps.podman.outcome == 'failure' }} + env: + REASON: ${{ steps.podman.outputs.reason }} + run: | + args=(--stage install=blocked --stage-reason "install=rootless Podman is unavailable: ${REASON:-unknown}") + for stage in initialize execute correctness; do args+=(--stage "$stage=blocked" --stage-reason "$stage=install did not pass"); done + args+=(--stage security=not-run --stage-reason "security=no security check runs on this lane") + args+=(--stage performance=not-run --stage-reason "performance=not measured on shared runners") + "$HOST_PYTHON" -I scripts/ci/collect_environment.py --no-node --profile-id "$PROFILE" --output "$RECORDS/$PROFILE.blocked.json" \ + --summary "$GITHUB_STEP_SUMMARY" "${args[@]}" + - name: Pull the pinned image and check its architecture + id: pull + run: | + podman pull --quiet "$IMAGE" + arch=$(podman image inspect --format '{{.Architecture}}' "$IMAGE") + echo "image architecture: $arch" + test "$arch" = "$IMAGE_ARCH" + podman image inspect --format 'created {{.Created}}; labels version {{index .Labels "version"}} release {{index .Labels "release"}}' "$IMAGE" + - name: Start the container as the runner's user + id: start + run: | + event=$RUNNER_TEMP/event.json + cp "$GITHUB_EVENT_PATH" "$event" + podman run --detach --name "$CONTAINER" --userns=keep-id --user "$(id -u):$(id -g)" \ + --cap-drop=all --security-opt=no-new-privileges \ + --volume "$GITHUB_WORKSPACE:/workspace" --workdir /workspace \ + --volume "$RECORDS:/records" --volume "$UV_DIR:/opt/uv:ro" --volume "$event:/github/event.json:ro" \ + --env HOME=/tmp --env UV_NO_CONFIG=1 --env UV_CACHE_DIR=/tmp/uv-cache --env UV_PYTHON_INSTALL_DIR=/tmp/uv-python \ + --env PATH=/opt/uv:/usr/local/bin:/usr/bin --env GITHUB_EVENT_PATH=/github/event.json \ + --env GITHUB_ACTIONS --env GITHUB_EVENT_NAME --env GITHUB_SHA --env GITHUB_REF --env GITHUB_REPOSITORY \ + --env GITHUB_SERVER_URL --env GITHUB_RUN_ID --env GITHUB_RUN_ATTEMPT --env GITHUB_WORKFLOW_REF --env GITHUB_JOB \ + --env RUNNER_ENVIRONMENT --env RUNNER_OS --env RUNNER_ARCH --env ImageOS --env ImageVersion \ + "$IMAGE" sleep infinity + podman exec "$CONTAINER" id + - name: Record the image's system Python, which the lane does not use + # UBI 9's system SQLite 3.34.1 is older than the 3.37.0 Django 6.1 needs; the lane uses uv's CPython 3.12.15. + run: | + code='import json, platform, sqlite3, sys; print(json.dumps({"used": False, "executable": sys.executable, "version": platform.python_version(), "sqlite": sqlite3.sqlite_version}))' + if podman exec "$CONTAINER" test -x /usr/bin/python3; then + podman exec "$CONTAINER" /usr/bin/python3 -I -c "$code" > "$RECORDS/system-python.json" + else + echo '{"used": false, "executable": null}' > "$RECORDS/system-python.json" + fi + cat "$RECORDS/system-python.json" + - name: Create a CPython 3.12.15 environment in the container + run: | + podman exec "$CONTAINER" uv venv --managed-python --python "$PYTHON_REQUEST" + podman exec "$CONTAINER" /workspace/.venv/bin/python -VV + podman exec "$CONTAINER" /workspace/.venv/bin/python -c 'import platform, sqlite3, os; print(platform.machine(), os.confstr("CS_GNU_LIBC_VERSION"), "SQLite", sqlite3.sqlite_version)' + test "$(podman exec "$CONTAINER" /workspace/.venv/bin/python -c 'import platform; print(platform.python_version())')" = 3.12.15 + - name: Check the lock's wheels for this interpreter + run: | + podman exec "$CONTAINER" /workspace/.venv/bin/python -I scripts/ci/probe_python_abi.py pin --lock "$LOCK" --name packaging --output /tmp/packaging.txt + podman exec "$CONTAINER" uv pip install --python /workspace/.venv/bin/python --require-hashes --only-binary :all: --no-deps -r /tmp/packaging.txt + podman exec "$CONTAINER" /workspace/.venv/bin/python -I scripts/ci/probe_python_abi.py wheels --lock "$LOCK" --output /records/wheels.json --summary /records/summary.md + - name: Install the hashed backend lock + id: install + run: | + sync=0 + podman exec "$CONTAINER" uv pip sync --python /workspace/.venv/bin/python --require-hashes --only-binary :all: "$LOCK" 2>&1 | tee "$RECORDS/install.log" || sync=$? + check=skip + if [ "$sync" = 0 ]; then + check=0 + podman exec "$CONTAINER" uv pip check --python /workspace/.venv/bin/python 2>&1 | tee -a "$RECORDS/install.log" || check=$? + podman exec "$CONTAINER" /workspace/.venv/bin/python -I scripts/ci/probe_python_abi.py requirements --lock "$LOCK" --output /records/requirements.json --summary /records/summary.md + fi + podman exec "$CONTAINER" /workspace/.venv/bin/python -I scripts/ci/probe_python_abi.py install-stage --sync "$sync" --check "$check" --log /records/install.log \ + --wheels /records/wheels.json --requirements /records/requirements.json --github-output /records/install-stage.txt || status=$? + if [ -f "$RECORDS/install-stage.txt" ]; then cat "$RECORDS/install-stage.txt" >> "$GITHUB_OUTPUT"; fi + exit "${status:-0}" + - name: Django check and migration drift + id: initialize + if: ${{ steps.install.outputs.status == 'passed' }} + run: | + podman exec --workdir /workspace/backend "$CONTAINER" /workspace/.venv/bin/python manage.py check + podman exec --workdir /workspace/backend "$CONTAINER" /workspace/.venv/bin/python manage.py makemigrations --check --dry-run + - name: Django tests + id: execute + if: ${{ steps.initialize.outcome == 'success' }} + run: | + podman exec --workdir /workspace/backend "$CONTAINER" /workspace/.venv/bin/python manage.py test core agent 2>&1 | tee "$RECORDS/django-tests.log" + grep -Eq '^Ran [1-9][0-9]* tests? in ' "$RECORDS/django-tests.log" || { echo '::error::Django ran no tests'; exit 1; } + - name: pytest from the hashed test lock + id: correctness + if: ${{ steps.execute.outcome == 'success' }} + run: | + podman exec "$CONTAINER" uv pip sync --python /workspace/.venv/bin/python --require-hashes --only-binary :all: "$TEST_LOCK" + podman exec "$CONTAINER" uv pip check --python /workspace/.venv/bin/python + podman exec --workdir /workspace/backend "$CONTAINER" /workspace/.venv/bin/python ../scripts/ci/check_test_parity.py + podman exec --workdir /workspace/backend "$CONTAINER" /workspace/.venv/bin/python -m pytest + - name: Record the container's environment, inside it + id: collect + if: ${{ !cancelled() && steps.start.outcome == 'success' }} + env: + INSTALL_OUTCOME: ${{ steps.install.outcome }} + INSTALL_STATUS: ${{ steps.install.outputs.status }} + INSTALL_REASON: ${{ steps.install.outputs.reason }} + INITIALIZE: ${{ steps.initialize.outcome }} + EXECUTE: ${{ steps.execute.outcome }} + CORRECTNESS: ${{ steps.correctness.outcome }} + run: | + args=() + ran() { + args+=(--stage "$1=$2" --stage-evidence "$1=log:compatibility.yml#ubi/$3" --stage-escalation "$1=none") + if [ -n "$4" ]; then args+=(--stage-reason "$1=$4"); fi + } + step() { + case "$2" in + success) ran "$1" passed "$3" "" ;; + failure) ran "$1" failed "$3" "the step failed; see its log" ;; + skipped) args+=(--stage "$1=blocked" --stage-reason "$1=$4 did not pass") ;; + *) args+=(--stage "$1=not-run" --stage-reason "$1=the step did not run") ;; + esac + } + case "$INSTALL_STATUS" in + passed|failed) ran install "$INSTALL_STATUS" "Install the hashed backend lock" "$INSTALL_REASON" ;; + blocked) args+=(--stage install=blocked --stage-reason "install=$INSTALL_REASON" --stage-evidence "install=log:compatibility.yml#ubi/Install the hashed backend lock") ;; + *) + if [ "$INSTALL_OUTCOME" = failure ]; then + ran install failed "Install the hashed backend lock" "the install step failed before it set a status" + else + args+=(--stage install=blocked --stage-reason "install=the install step did not run because an earlier step failed") + fi ;; + esac + step initialize "$INITIALIZE" "Django check and migration drift" install + step execute "$EXECUTE" "Django tests" initialize + step correctness "$CORRECTNESS" "pytest from the hashed test lock" execute + args+=(--stage security=not-run --stage-reason "security=no security check runs on this lane") + args+=(--stage performance=not-run --stage-reason "performance=not measured on shared runners") + for name in wheels requirements system-python; do + if [ -f "$RECORDS/$name.json" ]; then args+=(--extension "x-$name=/records/$name.json"); fi + done + podman exec "$CONTAINER" /workspace/.venv/bin/python -I scripts/ci/collect_environment.py --no-node --profile-id "$PROFILE" \ + --output "/records/$PROFILE.result.json" --summary /records/summary.md "${args[@]}" + - name: Add the engine's view of the container to the record + # podman info and podman container inspect, run on the host as the runner's user, show whether the engine + # runs rootless, whether the container is privileged, and which devices it was given; inside the container + # those are not visible. + id: engine + if: ${{ !cancelled() && steps.collect.outcome != 'skipped' }} + run: | + "$HOST_PYTHON" -I scripts/ci/collect_environment.py --amend-container "$RECORDS/$PROFILE.result.json" --podman-container "$CONTAINER" + - name: Check the record and the committed profiles + if: ${{ !cancelled() && steps.collect.outcome != 'skipped' }} + run: | + "$HOST_PYTHON" -I scripts/ci/validate_compatibility.py --require-real --dependency-strategy docs/DEPENDENCY-STRATEGY.md \ + --profile "tests/compatibility/profiles/$PROFILE.json" --summary "$GITHUB_STEP_SUMMARY" \ + "$RECORDS/$PROFILE.result.json" tests/compatibility/profiles/*.json + - name: Show the summaries written in the container + if: ${{ !cancelled() }} + run: | + if [ -f "$RECORDS/summary.md" ]; then cat "$RECORDS/summary.md" >> "$GITHUB_STEP_SUMMARY"; fi + - name: Remove the container + if: ${{ always() && steps.start.outcome != 'skipped' }} + run: podman rm --force --ignore --time 5 "$CONTAINER" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: ${{ !cancelled() }} + with: + name: compatibility-${{ matrix.lane }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/compatibility/ + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/python-abi.yml b/.github/workflows/python-abi.yml new file mode 100644 index 0000000..b3f1d8c --- /dev/null +++ b/.github/workflows/python-abi.yml @@ -0,0 +1,201 @@ +name: Python ABI + +# Probes CPython 3.15.0 and its free-threaded build (3.15t) on the GitHub-hosted runners: the interpreter's +# ABI facts (SOABI, Py_GIL_DISABLED, the GIL state, extension suffixes, platform and C library), which wheel +# of each native package in the backend lock this interpreter accepts and installs, and whether the package +# imports and runs once in a fresh process without enabling the GIL. Thread safety is not tested. The lock is +# resolved for CPython 3.12 with the GIL, so a pass here is no support status; see docs/DEPENDENCY-STRATEGY.md. + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/python-abi.yml' + - 'backend/locks/**' + - 'scripts/ci/**' + - 'tests/compatibility/**' + - 'docs/CI-MATRIX.md' + - 'docs/DEPENDENCY-STRATEGY.md' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A newer run for the same pull request cancels the older one; a manual dispatch gets its own group. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + abi: + # The Windows runners run the same steps in abi-windows below. + name: Python ABI / ${{ matrix.platform.lane }} / ${{ matrix.build }} + runs-on: ${{ matrix.platform.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + build: [cp315, cp315t] + platform: + - lane: linux-x64 + runner: ubuntu-24.04 + python: linux-x86_64-gnu + - lane: linux-arm64 + runner: ubuntu-24.04-arm + python: linux-aarch64-gnu + - lane: macos-arm64 + runner: macos-15 + python: macos-aarch64-none + - lane: macos-x64 + runner: macos-15-intel + python: macos-x86_64-none + env: &abi_env + # uv ignores uv.toml and pyproject.toml files, which a pull request could add. + UV_NO_CONFIG: '1' + LOCK: backend/locks/cp312-linux-x86_64.txt + PROFILE: gha-${{ matrix.platform.lane }}-${{ matrix.build }} + PYTHON_REQUEST: cpython-3.15.0${{ matrix.build == 'cp315t' && '+freethreaded' || '' }}-${{ matrix.platform.python }} + FREE_THREADED: ${{ matrix.build == 'cp315t' && '1' || '0' }} + # abi-windows runs these steps too (a YAML alias); they read GITHUB_JOB where the two jobs differ. + steps: &abi_steps + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + # The first uv release with CPython 3.15.0 builds; setup-uv v10.3.0 checks its download against the + # checksum it embeds. The backend jobs keep uv 0.12.24. + version: '0.13.0' + enable-cache: false + # With a Python version given, setup-uv does not look for an interpreter in the checkout and run it. + python-version: cpython-3.15.0${{ matrix.build == 'cp315t' && '+freethreaded' || '' }}-${{ matrix.platform.python }} + - name: Create a CPython 3.15.0 environment + run: | + # The Windows runners' administrator token is allowed only in abi-windows, under its condition and exception. + if [ "$RUNNER_OS" = Windows ] && [ "$GITHUB_JOB" != abi-windows ]; then echo "::error::Windows lanes run only in the abi-windows job"; exit 1; fi + mkdir -p "$RUNNER_TEMP/python-abi" + echo "RECORDS=$RUNNER_TEMP/python-abi" >> "$GITHUB_ENV" + uv --version + uv venv --managed-python --python "$PYTHON_REQUEST" + if [ "$RUNNER_OS" = Windows ]; then + venv_python="$GITHUB_WORKSPACE/.venv/Scripts/python.exe" + uv_path=$(cygpath -w "$(command -v uv)") + else + venv_python="$GITHUB_WORKSPACE/.venv/bin/python" + uv_path=$(command -v uv) + fi + echo "VENV_PYTHON=$venv_python" >> "$GITHUB_ENV" + echo "UV_PATH=$uv_path" >> "$GITHUB_ENV" + "$venv_python" -VV + test "$("$venv_python" -c 'import platform; print(platform.python_version())')" = 3.15.0 + # Py_GIL_DISABLED is 1 in a free-threaded build and 0 otherwise. + test "$("$venv_python" -c 'import sysconfig; print(sysconfig.get_config_var("Py_GIL_DISABLED") or 0)')" = "$FREE_THREADED" + - name: Install packaging from the lock + # The probe reads wheel tags with packaging, a pure-Python wheel pinned and hashed in the lock. + run: | + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py pin --lock "$LOCK" --name packaging --output "$RUNNER_TEMP/packaging.txt" + uv pip install --python "$VENV_PYTHON" --require-hashes --only-binary :all: --no-deps -r "$RUNNER_TEMP/packaging.txt" + - name: Test the probe on this interpreter + run: | + "$VENV_PYTHON" -I scripts/ci/tests/test_python_abi_probe.py --verbose + - name: Probe the lock's native packages + # For each package: the wheels PyPI lists for the locked version and the ABI each serves, the one this + # interpreter installs from the lock (binary only, with the lock's hashes, with only the lock packages it needs), and an + # import and a call in a fresh process with the GIL state before and after. Exits 1 on an unexpected + # install failure or a failed import or call; a package with no wheel for this interpreter is blocked. + id: packages + run: | + "$VENV_PYTHON" -I scripts/ci/probe_python_abi.py packages --lock "$LOCK" --uv "$UV_PATH" \ + --output "$RECORDS/python-abi.json" --summary "$GITHUB_STEP_SUMMARY" --github-output "$GITHUB_OUTPUT" + - name: Record this runner's environment + id: collect + if: ${{ !cancelled() }} + env: + OUTCOME: ${{ steps.packages.outcome }} + INSTALL: ${{ steps.packages.outputs.install }} + INSTALL_REASON: ${{ steps.packages.outputs.install-reason }} + run: | + evidence="log:python-abi.yml#$GITHUB_JOB/Probe the lock's native packages" + case "$INSTALL" in + passed|failed) args=(--stage "install=$INSTALL" --stage-evidence "install=$evidence" --stage-escalation install=none --stage-reason "install=$INSTALL_REASON") ;; + blocked) args=(--stage install=blocked --stage-reason "install=$INSTALL_REASON" --stage-evidence "install=$evidence") ;; + *) + if [ "$OUTCOME" = failure ]; then + args=(--stage install=failed --stage-evidence "install=$evidence" --stage-escalation install=none --stage-reason "install=the probe failed before it set a status") + else + args=(--stage install=blocked --stage-reason "install=the probe did not run because an earlier step failed") + fi ;; + esac + for stage in initialize execute correctness; do + args+=(--stage "$stage=not-run" --stage-reason "$stage=this lane probes the lock's native packages only; x-python-abi has each import and call") + done + security="no security check runs on this lane" + if [ "$GITHUB_JOB" = abi-windows ] && [ "$RUNNER_OS" = Windows ]; then + # The temporary Windows administrator-token exception (docs/SECURITY-ARCHITECTURE.md): the record itself + # says that the policy is skipped and security is not verified, so it never reads as a non-administrator result. + security="not verified: no security check runs on this lane, and the privilege policy is skipped under the temporary Windows administrator-token exception" + printf '%s\n' '{"policy": "skipped", "security": "not-verified", "compatibility": "incomplete", "non_administrator_run": "not-run", "reason": "GitHub-hosted Windows runners give the job user an elevated administrator token only", "exception": "docs/SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token"}' > "$RECORDS/policy-exception.json" + args+=(--extension "x-policy-exception=$RECORDS/policy-exception.json") + fi + args+=(--stage security=not-run --stage-reason "security=$security") + args+=(--stage performance=not-run --stage-reason "performance=not measured on shared runners") + if [ -f "$RECORDS/python-abi.json" ]; then args+=(--extension "x-python-abi=$RECORDS/python-abi.json"); fi + "$VENV_PYTHON" -I scripts/ci/collect_environment.py --no-node --profile-id "$PROFILE" --output "$RECORDS/$PROFILE.result.json" \ + --summary "$GITHUB_STEP_SUMMARY" "${args[@]}" + - name: Check the record and the committed profiles + if: ${{ !cancelled() && steps.collect.outcome != 'skipped' }} + run: | + policy=() + if [ "$GITHUB_JOB" = abi-windows ] && [ "$RUNNER_OS" = Windows ]; then + # Only here, under the temporary Windows administrator-token exception (docs/SECURITY-ARCHITECTURE.md): the + # job's user is an administrator, so the record cannot pass app-non-root. The validator reports policy-skipped. + policy=(--no-policy) + echo "::warning title=Privilege policy skipped::Windows administrator token: policy skipped, security not verified, compatibility incomplete. This is not a non-administrator result." + printf '%s\n' "### Windows administrator-token exception" "" \ + "- Privilege policy: skipped (\`--no-policy\`); the validator reports \`policy-skipped\`." \ + "- Security: not verified. Compatibility: incomplete." \ + "- Not a non-administrator result. See docs/SECURITY-ARCHITECTURE.md, \"Temporary exception: Windows administrator token\"." "" >> "$GITHUB_STEP_SUMMARY" + fi + "$VENV_PYTHON" -I scripts/ci/validate_compatibility.py --require-real --dependency-strategy docs/DEPENDENCY-STRATEGY.md "${policy[@]}" \ + --profile "tests/compatibility/profiles/$PROFILE.json" --summary "$GITHUB_STEP_SUMMARY" \ + "$RECORDS/$PROFILE.result.json" tests/compatibility/profiles/*.json + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: ${{ !cancelled() }} + with: + name: python-abi-${{ matrix.platform.lane }}-${{ matrix.build }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/python-abi/ + if-no-files-found: warn + # The record and the probe report are the lane's only machine-readable evidence: keep them 90 days, GitHub's default for logs. + retention-days: 90 + + abi-windows: + # The abi steps on GitHub-hosted Windows runners, whose job user holds an elevated administrator token only. + # They run under the temporary exception in docs/SECURITY-ARCHITECTURE.md ("Temporary exception: Windows + # administrator token"): for a pull request from a branch of this repository, and for a manual run on main, + # which needs write access to start. A fork's pull request skips this job: an intentional not-run, never a pass. + # A job's if is evaluated before its matrix is expanded, so it reads only the github context. + if: >- + ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} + name: Python ABI / ${{ matrix.platform.lane }} / ${{ matrix.build }} + runs-on: ${{ matrix.platform.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + build: [cp315, cp315t] + platform: + - lane: windows-x64 + runner: windows-2025 + python: windows-x86_64-none + - lane: windows-arm64 + runner: windows-11-arm + python: windows-aarch64-none + env: *abi_env + steps: *abi_steps diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 894f274..5f10704 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ This document distinguishes the current runnable scaffold from longer-term archi ## Current runnable scaffold - Frontend: Next.js user surface at `/` and a separate product-operations Console reserved under `/console/*` (only the `/console` index page exists today), using React, TypeScript, Tailwind CSS, axios, SweetAlert2, and, for tests, Node Playwright and Vitest. -- Backend baseline: Django 6.1, Django REST Framework 3.18, django-cors-headers, FastAPI, Daphne, Uvicorn, psycopg (PostgreSQL driver), boto3 (S3-compatible storage client), and Python Playwright. The checked lane is CPython 3.12.15 on Linux x86-64 (glibc) with a hashed lock. Other lanes have static evidence only or are unsupported: Linux arm64 with glibc (for example NVIDIA DGX Spark) and macOS 15 or later on Apple Silicon are statically resolvable from the same lock, Windows through WSL2 uses the matching Linux lane, and native Windows, including Windows on Arm, cannot produce a working environment from the lock today; see [Platform and accelerator lanes](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes). No dependency is CUDA- or accelerator-specific today; a future accelerator package stays optional, keeps a CPU path, and gets its own lane, with CUDA on Arm (DGX Spark, RTX Spark) kept distinct from Arm without CUDA (for example Qualcomm Snapdragon). Django's wider 3.12–3.14 support does not qualify every Claus runtime combination. +- Backend baseline: Django 6.1, Django REST Framework 3.18, django-cors-headers, FastAPI, Daphne, Uvicorn, psycopg (PostgreSQL driver), boto3 (S3-compatible storage client), and Python Playwright. The checked lane is CPython 3.12.15 on Linux x86-64 (glibc) with a hashed lock. Other lanes are not checked; they have static evidence, and since 2026-10-12 some also CI observations that change no status, or are unsupported: Linux arm64 with glibc (for example NVIDIA DGX Spark) and macOS 15 or later on Apple Silicon are statically resolvable from the same lock, Windows through WSL2 uses the matching Linux lane, and native Windows, including Windows on Arm, cannot produce a working environment from the lock today; see [Platform and accelerator lanes](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes). No dependency is CUDA- or accelerator-specific today; a future accelerator package stays optional, keeps a CPU path, and gets its own lane, with CUDA on Arm (DGX Spark, RTX Spark) kept distinct from Arm without CUDA (for example Qualcomm Snapdragon). Django's wider 3.12–3.14 support does not qualify every Claus runtime combination. - Django project: `config`. - Django apps: `core` for the persistent product/control plane and `agent` for AI/RAG/agent execution. - Routing: `/core/*` uses Django/DRF, `/agent/*` uses FastAPI, and `/admin/*` remains Django Admin. diff --git a/docs/CI-MATRIX.md b/docs/CI-MATRIX.md index bec40e5..8562748 100644 --- a/docs/CI-MATRIX.md +++ b/docs/CI-MATRIX.md @@ -2,20 +2,21 @@ Added 2026-10-11. This document defines how a compatibility lane is described, how a run on a lane is recorded, and what the validator checks. Commands and the CI job are listed in [TESTING.md](TESTING.md); which lanes are supported is recorded only in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes); CI permissions and trust boundaries are in [SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md) "CI workflow". -Implemented today: the schema, the validator, the environment collector, one real profile, nine synthetic fixtures, their tests, and one CI job on the checked lane (GitHub-hosted `ubuntu-24.04` x86-64). No other lane runs in CI. Nothing in this model is a support claim, and a synthetic fixture is not evidence that any platform works. +Implemented today: the schema, the validator, the environment collector (Linux, macOS, and Windows probes, and a Podman engine step on the host), the wheel and Python ABI probe, 24 real profiles, nine synthetic fixtures, their tests, and the CI jobs that record real runs: the `compatibility` job in `.github/workflows/ci.yml` on the checked lane, `.github/workflows/compatibility.yml` on seven GitHub-hosted runner lanes and four UBI container lanes, and `.github/workflows/python-abi.yml` with CPython 3.15.0 and 3.15t on six runners (added 2026-10-12; see "Current lanes and fixtures"). Nothing in this model is a support claim: a run is evidence about one runner image and one commit, and a synthetic fixture is not evidence that any platform works. ## Files | Path | Role | |---|---| | `tests/compatibility/schema.json` | Structure of profiles and results, written in the JSON Schema subset described under "Structure" below. | -| `tests/compatibility/profiles/` | Real profiles: one per lane that a CI job records. Today only `linux-x64-gha.json`. | +| `tests/compatibility/profiles/` | Real profiles: one per lane that a CI job records, listed under "Current lanes and fixtures". | | `tests/compatibility/fixtures/` | Synthetic profiles and results for scenarios no job runs (Windows on Arm, WSL2, Podman, Kubernetes, CUDA). They test the model, not a platform. | | `scripts/ci/collect_environment.py` | Records the environment of the process that runs it as a result document. | | `scripts/ci/validate_compatibility.py` | Checks profiles and results: structure from the schema, then the rules under "Rules across fields". | -| `scripts/ci/tests/test_compatibility_schema.py`, `scripts/ci/tests/test_environment_probe.py` | Tests of the validator and the collector, including documents that must fail. | +| `scripts/ci/probe_python_abi.py` | Reports which wheel of each backend lock package the running interpreter accepts and installs, and decides a lane's `install` stage (see "Install stage in the lane jobs"). | +| `scripts/ci/tests/test_compatibility_schema.py`, `scripts/ci/tests/test_environment_probe.py`, `scripts/ci/tests/test_python_abi_probe.py` | Tests of the validator, the collector, and the wheel probe, including documents and outcomes that must fail. | -All of these use the Python standard library only and run without Django. +All of these run without Django. All but the wheel probe and its tests use the Python standard library only; the wheel probe reads wheel tags with `packaging`, which the jobs install from the backend lock with its hash. ## Profiles and results @@ -87,6 +88,8 @@ Evidence references have the form `:`, with kind `file`, `env`, `native` means no translation of the instructions was found, not that the hardware was shown to run them; `emulated` means some layer translates the instructions. The collector compares the architecture a process was built for with the kernel's own architecture from `/proc/sys/kernel/arch` (Linux 6.1 and later), which qemu-user's `uname` interception does not rewrite. The Python architecture comes from the interpreter's build triplet (`sys.implementation._multiarch`, then `SOABI`, then the ELF header of the executable, read only when its byte order and ELF class are those of the architecture its `e_machine` names), not from `sysconfig.get_platform()`, which on Linux follows `uname`. Without `/proc/sys/kernel/arch`, a process is reported `emulated` only when `/proc/cpuinfo` shows another CPU family, and `unknown` otherwise. `x86` code on an `x86_64` kernel is native. `armv7` code on an `aarch64` kernel is native only on cores that implement AArch32, and many recent Arm cores implement AArch64 only, so the collector records that pair as `unknown`; the validator accepts either value for it. On a kernel without `/proc/sys/kernel/arch`, such as any kernel before 6.1 that has not backported it, the layer's `cpu.arch` and most runtimes' `execution` stay `unknown`, so a profile for such a lane leaves them `unknown` too. +On macOS and Windows the machine's architecture is read from the system, not from the process, so an emulated process still names the machine: on macOS `hw.optional.arm64` (1 on Apple silicon, also inside Rosetta 2) or `hw.optional.x86_64`, and on Windows the native machine that `IsWow64Process2` returns, also to an x64 process emulated on Arm. On Windows `platform.machine()` reports that machine, not the process's build, so the Python architecture comes from `sysconfig.get_platform()` there. A runtime whose architecture differs from the machine's is `emulated`, because neither system runs another architecture's code except through an emulator (Rosetta 2, or Windows on Arm's x64 emulation); on Apple silicon `sysctl.proc_translated` decides it for the Python process directly. Without these values the execution is `unknown`. + Emulation is inherited. The validator rejects a layer or a runtime recorded as `native` when an enclosing layer is recorded as `emulated`, or when its architecture cannot run natively on an enclosing layer whose architecture is known, not only the one directly below it. Limits: a whole system emulated by its hypervisor looks native from inside; translators that also present the emulated architecture as the kernel's (such as FEX or box64 setups, or Rosetta inside a Linux VM) have not been evaluated; and the layer that runs the kernel records its own `cpu.execution` as `unknown`, because a guest cannot see whether the layer below emulates it. A container layer's `cpu.arch` and `cpu.execution` are `unknown` in a collected record: the container runs on the CPU of the layer that runs the kernel, and the programs in it need not all be built for one architecture, so each runtime records its own architecture and execution. A profile or a synthetic record may still give a container layer `cpu` facts; the rules above apply to them as to any other layer's. @@ -121,11 +124,11 @@ A rootless engine does not make the process inside the container non-root: uid 0 | `minimal-capabilities` | required | The workload holds no effective and no permitted capabilities. | | `minimal-devices` | required | No device is passed to a container. | -A rule passes only when the record shows compliance with `observed` or `inferred` facts: an `unknown` or `declared` value counts as a violation, except a stage's escalation, which the job states (see "Facts and provenance"). `minimal-capabilities` checks the effective and permitted sets the workload holds. The bounding set, which limits what a setuid program could gain, is recorded but not checked, because a non-root process on an ordinary host has the full bounding set. Inside a user namespace other than the initial one, capabilities apply to that namespace only; the rule still counts them. A violation of a required rule is an error, and of a preferred rule a warning. A profile can raise a rule from preferred to required; it cannot lower a required rule (`policy-weakened`). `not-applicable` effective or permitted capabilities pass `minimal-capabilities` only when the innermost layer's OS family is measured and is not Linux. Windows has no Linux capabilities, but the model does not record Windows token privileges either, so a Windows record with `not-applicable` capabilities passes this rule without showing that its token holds no privilege. Off Linux the collector records the isolation facts as `unknown` and, on Windows, `superuser` as `unknown`, so a collected macOS or Windows record fails `minimal-capabilities`, and on Windows `app-non-root`, until a probe for that system records them. +A rule passes only when the record shows compliance with `observed` or `inferred` facts: an `unknown` or `declared` value counts as a violation, except a stage's escalation, which the job states (see "Facts and provenance"). `minimal-capabilities` checks the effective and permitted sets the workload holds. The bounding set, which limits what a setuid program could gain, is recorded but not checked, because a non-root process on an ordinary host has the full bounding set. Inside a user namespace other than the initial one, capabilities apply to that namespace only; the rule still counts them. A violation of a required rule is an error, and of a preferred rule a warning. A profile can raise a rule from preferred to required; it cannot lower a required rule (`policy-weakened`). `not-applicable` effective or permitted capabilities pass `minimal-capabilities` only when the innermost layer's OS family is measured and is not Linux. Windows has no Linux capabilities, but the model does not record Windows token privileges either, so a Windows record with `not-applicable` capabilities would pass this rule without showing that its token holds no privilege. The collector therefore records the capabilities on Windows as `unknown` (the token's privileges are not read), and its Windows records fail `minimal-capabilities`. On macOS and Windows it records the other Linux isolation interfaces (namespaces, cgroups, seccomp, AppArmor, SELinux), and on macOS the capabilities too, as `not-applicable`. On Windows it records `superuser` from the process token: an elevated token, the Administrators group enabled in it, or LocalSystem makes it `true`, and it is `false` only when all three are read and none holds; a token that cannot be read fully and shows no administrator leaves it `unknown`. GitHub-hosted Windows runners run jobs as an administrator, so their records fail `app-non-root`. Under a temporary exception ([SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token)), the `platform-windows` and `abi-windows` jobs, and no other job, run the validator with `--no-policy` on a Windows runner, which reports `policy-skipped`. Their records hold `x-policy-exception` (policy `skipped`, security `not-verified`, compatibility `incomplete`, non-administrator run `not-run`), and the `security` stage's reason says the same, so such a record is no evidence of a non-administrator run. The exception does not change this policy: every other record, including a Windows record validated anywhere else, is held to it. Those two jobs run only for a pull request from a branch of this repository and for a manual run on `main`; a fork's pull request skips them, which is a not-run, not a pass ([TESTING.md](TESTING.md) "Compatibility and Python ABI workflows"). -A layer that any fact, even a declared one, calls a container is held to the container rules, which only measured facts pass. The container rules also apply when no layer is a container but the record does not show the workload outside one. On Linux, or when the innermost layer's OS family is not measured, unless `pid_namespace.initial` is measured as true, `no-privileged-container` and `minimal-devices` fail and `engine-rootless` warns at `/workload/isolation/pid_namespace/initial`, because a container that leaves none of the markers the collector reads would otherwise pass. No exception covers this finding; record the container layer instead, so that an exception can cover that layer. The rule does not apply when the innermost layer is `wsl2`, because WSL2 runs every distribution in its own PID namespace inside its VM, so there the namespace does not suggest a container; a container in a WSL2 distribution that leaves no marker is therefore not caught. Nor is a container that shares the initial PID namespace and leaves no marker. On another measured OS family no namespace probe applies, so the innermost layer's `kind` must be measured as `host` or `vm`; otherwise the same three findings are reported at that `kind`. The collector records the kind as `unknown` off Linux, so a collected macOS or Windows record fails these rules too. +A layer that any fact, even a declared one, calls a container is held to the container rules, which only measured facts pass. The container rules also apply when no layer is a container but the record does not show the workload outside one. On Linux, or when the innermost layer's OS family is not measured, unless `pid_namespace.initial` is measured as true, `no-privileged-container` and `minimal-devices` fail and `engine-rootless` warns at `/workload/isolation/pid_namespace/initial`, because a container that leaves none of the markers the collector reads would otherwise pass. No exception covers this finding; record the container layer instead, so that an exception can cover that layer. The rule does not apply when the innermost layer is `wsl2`, because WSL2 runs every distribution in its own PID namespace inside its VM, so there the namespace does not suggest a container; a container in a WSL2 distribution that leaves no marker is therefore not caught. Nor is a container that shares the initial PID namespace and leaves no marker. On another measured OS family no namespace probe applies, so the innermost layer's `kind` must be measured as `host` or `vm`; otherwise the same three findings are reported at that `kind`. On macOS the collector infers `vm` from `kern.hv_vmm_present` 1 and names the hypervisor from `hw.model` where it knows the prefix; on Windows it infers `vm` from BIOS manufacturer and product values that name a hypervisor (`Microsoft Corporation` and `Virtual Machine` are Hyper-V, Azure included). It never infers bare metal: with no such signal the kind stays `unknown`, and these rules fail. -Inside a container the collector cannot see the privileged state or the devices, and Podman writes the engine's rootless state to `/run/.containerenv` only for privileged containers, so a container lane's record fails the default policy until its job records those facts from the engine side, outside the container; no job does that yet. Docker writes no such file, and an image can contain one, so the collector names no engine and no mode when its markers disagree. +Inside a container the collector cannot see the privileged state or the devices, and Podman writes the engine's rootless state to `/run/.containerenv` only for privileged containers, so a container lane's record fails the default policy until its job records those facts from the engine side, outside the container. `collect_environment.py --amend-container RECORD --podman-container NAME`, run on the host as the user that runs the container, does that: it reads `host.security.rootless` from `podman info` and `HostConfig.Privileged` and `HostConfig.Devices` from `podman container inspect NAME`, and writes them into the record's innermost container layer as `observed`, changing nothing else. It writes nothing when podman fails, when the output does not describe that one running container, or when the record's innermost layer is not a container. The `ubi` job of `compatibility.yml` runs it. A rootful engine or a privileged container is recorded as it is, so the policy then fails. Docker writes no such file, and an image can contain one, so the collector names no engine and no mode when its markers disagree; no step reads Docker's engine facts. A profile that needs to break a rule records an exception with `id`, `rule`, `reason`, `scope`, `verification`, `end_condition`, and `approval` (`approved`, `pending`, or `rejected`, with a reference and a date when approved; a date given must be a day that exists). The scope names every value the exception covers, under the keys its rule uses and no other: `layers` for `engine-rootless` and `no-privileged-container`, both `stages` and `escalations` (`sudo`, `root-login`, `setuid`, `other`) for `no-privilege-escalation`, `capabilities` for `minimal-capabilities`, and `devices` for `minimal-devices`; `app-non-root` takes an empty scope. A violation is covered only when each of its values is listed, so an exception for `sudo` in `install` does not cover `setuid` there, and no exception covers a state that is not measured. Only an approved exception covers a violation; a pending or rejected one leaves the error (`policy-exception-not-approved`), and an approved one that covers nothing gives a warning. The validator checks that the approval is recorded, not that it is genuine, so every use of an approved exception gives a `policy-exception-applied` warning that names it, what it covers, and its approval reference and date; reviewing that reference is a reviewer's step ([SECURITY-REVIEW.md](SECURITY-REVIEW.md)). The approval has no approver or expiry field: the reference and the `end_condition` carry them. A profile cannot hold observed facts, so an exception cannot be used to state facts a run did not show. `--no-policy` skips these rules and reports a `policy-skipped` warning. @@ -143,7 +146,20 @@ A profile that needs to break a rule records an exception with `id`, `rule`, `re Statuses: `passed`, `failed`, `blocked` (could not run because a prerequisite failed or something it needs is missing), `not-run` (nobody ran it), and `not-applicable`. Only `security` and `performance` can be `not-applicable`: every workload is detected, installed, initialized, run, and checked, so those stages are `not-run` or `blocked` when nobody ran them. A `passed` or `failed` stage cites the evidence that shows it and records its privilege escalation; in a real result that evidence includes a `log:` or `artifact:` reference, and `detect` may cite the collector (`tool:`) instead. Every status other than `passed` gives a reason; a `not-run` stage cites nothing; a stage can be `passed` or `failed` only when every stage it needs is `passed` or `not-applicable`; and `detect` cannot be `passed` while `probe_errors` lists a failed probe. -`overall` is derived: `failed` if any stage failed, `passed` if every stage is `passed` or `not-applicable` and at least one passed, and `incomplete` otherwise. So a `passed` run has passed every stage from `detect` to `correctness`. The current CI job runs `detect` only, so its records are `incomplete` by design. +`overall` is derived: `failed` if any stage failed, `passed` if every stage is `passed` or `not-applicable` and at least one passed, and `incomplete` otherwise. So a `passed` run has passed every stage from `detect` to `correctness`. No CI job runs `security` or `performance`, which it records as `not-run`, so every record from CI is `incomplete` or `failed`: read its stages. The `compatibility` job in `ci.yml` runs `detect` only, the `python-abi.yml` lanes `detect` and `install`, and the `compatibility.yml` lanes `detect` through `correctness`. + +### Install stage in the lane jobs + +In `compatibility.yml` and `python-abi.yml` a failed install is told apart from a missing prerequisite by `scripts/ci/probe_python_abi.py`, not by the step outcome alone. Before the install, its `wheels` command lists, for every lock package, the files PyPI's JSON API lists for the pinned version, which of them this interpreter accepts (a tag from `packaging.tags.sys_tags()`, not yanked, and a Requires-Python that admits its version, as installers check), and whether the lock holds their hashes; a package with no accepted file whose hash is in the lock is uncovered. After `uv pip sync --require-hashes --only-binary :all:` and `uv pip check`, its `install-stage` command decides: + +| Outcome | `install` | +|---|---| +| The sync and the check passed, and no package is uncovered. | `passed` | +| The sync failed, some packages are uncovered, and the installer's output names one of them on a line that says it has no wheel (uv 0.12 prints "has no usable wheels", "has no wheels with a matching ... tag", or "Wheels are required for"). | `blocked`: the lock is the missing prerequisite. The reason names the packages, and those with a compatible wheel published after the lock. | +| The sync passed, and every problem `uv pip check` prints, as many as the count it prints, is a requirement that is not installed and that the lock does not pin, such as `tzdata`, which Django and psycopg require on Windows, and the requirements report agrees. | `blocked`, naming each requirement and what needs it. | +| The sync failed with nothing uncovered, or without naming an uncovered package; the sync passed although a package is uncovered; the check failed for a requirement the lock pins, for an installed version a requirement excludes, for any other problem it prints, with a count that does not match its lines, or without a requirements report; or a report is missing. | `failed`, and the step fails the job. | + +A `blocked` install leaves the job green, the later stages `blocked`, and the reason in the record and the step summary. A lane whose profile requires a stage that was `blocked` still fails at validation (`required-stage`). In `python-abi.yml` the probe's `packages` command installs each native package on its own, `--no-deps`, from the lock's hashes, and gives `install` the same split: a package with no wheel for the interpreter, or whose only compatible wheel is not in the lock, is `blocked`; an installer failure while a compatible wheel is in the lock, an install the reports contradict, or a lock dependency installed at a version a requirement excludes, is `failed`. The probe's import and call fail, and fail the job, when either raises, when a package enables the GIL in a build that started without it (the report's `gil_reenabled`), or when their process does not exit with status 0, even after it printed a passing result: the report keeps what the process printed and records how it ended under `exit` (`status`, `returncode`, and on POSIX the `signal` that ended it), and a process that prints no result is a failed import. A stage that used a compute backend records `compute.requested` and `compute.effective` as backend IDs. A stage cannot pass when a `gpu` or `npu` backend was requested and another backend, or none, ran (`accelerator-fallback`): record such a run as `failed` with the reason, or request the CPU backend for a CPU run. A profile of an accelerator lane names its `gpu` or `npu` backend in `accelerator_backend`; then each of `execute`, `correctness`, and `performance` that the profile requires must record that backend as both requested and effective (`required-stage`), so a run that used only the CPU, or recorded no backend, does not satisfy the lane. @@ -216,7 +232,9 @@ These are kept apart. A file the collector cannot read gives an `unknown` fact w ## Collector -The collector reads `/proc`, `/sys`, `/etc/os-release` (or `/usr/lib/os-release`), `/run/.containerenv`, and `/run/systemd/container`; checks whether `/.dockerenv`, `/.singularity.d`, and `/run/systemd/system` exist; reads the first 20 bytes (the ELF header) of the interpreter's executable when the build architecture is not otherwise known; reads the checkout's `.git/HEAD` and the loose ref it points to, or the `packed-refs` line when there is no loose ref (a ref under `refs/` of at most 200 characters that holds no character the validator refuses in any value nor a space, `~`, `^`, `:`, `?`, `*`, `[` or `\`, and not one with `..` or `@{`, a part that is empty, starts with `.`, or ends with `.lock` or `.`, a byte that is not UTF-8, or, when the file system encoding is not UTF-8, a character that is not ASCII), the head and base SHAs from the GitHub Actions event file, and a fixed allowlist of environment variables; and runs `node -p`, looked for in the `PATH` directories only (on Windows only as `node.exe`, whatever `PATHEXT` lists, so a `node.bat` or `node.com` earlier on `PATH` does not run in its place, without `shutil.which` and so with any CPython version, and not in the current directory, where `shutil.which` looks first, unless `PATH` names it, nor in the one `os.defpath` lists first when `PATH` is unset, and an empty `PATH` entry names no directory there), with an empty environment (on Windows, `SystemRoot` only), reading at most 4 KiB of its output and stopping it, with its process group, after 30 seconds. A descendant of `node` that starts a session of its own leaves that group and can keep the read waiting until it exits or the job's 10-minute timeout ends it. It reads a file only when it is a regular file, and opens it so that a FIFO cannot block the probe. Its docstring keeps the same list. It never iterates the environment, uses no network, never escalates privileges, changes no system state (it does not touch `binfmt_misc`, which can trigger an automount, and decides cgroup delegation from permission bits without writing), and writes only its output file and the optional summary. It records no host name, account name, home directory, network address, serial number, or other environment variable. It refuses `--stage-reason` and `--stage-evidence` values shaped like a token or a private key, and does not write a record that holds such a value. On systems other than Linux it records the OS family but not its version, so a real macOS profile that declares the macOS version its status needs fails against its run as `profile-unverified` until the collector reads that version; it also records the user and group IDs on FreeBSD (`os.getresuid`) and macOS (`os.geteuid` and `os.getuid`), the Python and Node runtimes, and the run, and leaves the layer kind and the isolation facts `unknown`. macOS has no `getresuid`, so there the saved user ID is inferred from the effective one, which `execve(2)` saves. On any other system, such as Cygwin or MSYS2, user ID 0 does not name the Windows administrator, so the user IDs are `unknown`. On Windows `superuser` is `unknown`, because the collector does not read the token, and the real and saved user IDs are `not-applicable`. +The collector reads `/proc`, `/sys`, `/etc/os-release` (or `/usr/lib/os-release`), `/run/.containerenv`, and `/run/systemd/container`; checks whether `/.dockerenv`, `/.singularity.d`, and `/run/systemd/system` exist; reads the first 20 bytes (the ELF header) of the interpreter's executable when the build architecture is not otherwise known; reads the checkout's `.git/HEAD` and the loose ref it points to, or the `packed-refs` line when there is no loose ref (a ref under `refs/` of at most 200 characters that holds no character the validator refuses in any value nor a space, `~`, `^`, `:`, `?`, `*`, `[` or `\`, and not one with `..` or `@{`, a part that is empty, starts with `.`, or ends with `.lock` or `.`, a byte that is not UTF-8, or, when the file system encoding is not UTF-8, a character that is not ASCII), the head and base SHAs from the GitHub Actions event file, and a fixed allowlist of environment variables; and runs `node -p`, looked for in the `PATH` directories only (on Windows only as `node.exe`, whatever `PATHEXT` lists, so a `node.bat` or `node.com` earlier on `PATH` does not run in its place, without `shutil.which` and so with any CPython version, and not in the current directory, where `shutil.which` looks first, unless `PATH` names it, nor in the one `os.defpath` lists first when `PATH` is unset, and an empty `PATH` entry names no directory there), with an empty environment (on Windows, `SystemRoot` only), reading at most 4 KiB of its output and stopping it, with its process group, after 30 seconds. A descendant of `node` that starts a session of its own leaves that group and can keep the read waiting until it exits or the job's timeout ends it. It reads a file only when it is a regular file, and opens it so that a FIFO cannot block the probe. Its docstring keeps the same list. It never iterates the environment, uses no network, never escalates privileges, changes no system state (it does not touch `binfmt_misc`, which can trigger an automount, and decides cgroup delegation from permission bits without writing), and writes only its output file and the optional summary. It records no host name, account name, home directory, network address, serial number, or other environment variable. It refuses `--stage-reason` and `--stage-evidence` values shaped like a token or a private key, and does not write a record that holds such a value. On macOS it reads, instead of the Linux files, these sysctl values: `kern.hv_vmm_present` and `hw.model` (a VM and its hypervisor), `kern.osproductversion` and `kern.osrelease` (the OS version, such as `15.7.1`, and the Darwin kernel release), `hw.optional.arm64` and `hw.optional.x86_64` (the machine), and `sysctl.proc_translated` (Rosetta 2); and the user and group IDs (`os.geteuid` and `os.getuid`). macOS has no `getresuid`, so there the saved user ID is inferred from the effective one, which `execve(2)` saves. On Windows it reads `platform.version()` (the OS version, which is also the NT kernel's build), the native machine from `IsWow64Process2`, the `SystemManufacturer` and `SystemProductName` values under `HKLM\HARDWARE\DESCRIPTION\System\BIOS`, and the process token (elevation, enabled Administrators membership, LocalSystem); the user and group IDs are `not-applicable` there. On both it records the Linux isolation interfaces as `not-applicable`, except the capabilities on Windows, which are `unknown` because the token's privileges are not read, and the service manager as `unknown`. On any other system it records the OS family, the user and group IDs on FreeBSD (`os.getresuid`), the Python and Node runtimes, and the run, and leaves the layer kind and the isolation facts `unknown`; under Cygwin or MSYS2 user ID 0 does not name the Windows administrator, so the user IDs are `unknown`. + +With `--extension x-NAME=PATH` it adds a JSON object file of at most 1 MiB, unchanged, under the record's `extensions`, which no profile comparison reads; the lane jobs add the wheel, requirements, Python ABI, and system Python reports this way. It describes its own process. It does not see the privileges or environment of other steps in the same job; a job that runs later stages passes their status, evidence, and escalation with the `--stage`, `--stage-reason`, `--stage-evidence`, and `--stage-escalation` options. The statuses are recorded as given and the escalation as `declared`, so a stage's status is only as good as the step that sets it (see "Adding a lane"). It does not assume a guest can see its physical host: on a VM, the host layer's facts are `unknown`. With no container marker and the initial PID namespace, or under WSL2 the distribution's own, it takes the root file system's os-release and PID 1 to be those of the layer that runs the kernel, and records them as `inferred`, because a chroot or a container that shares that namespace without a marker looks the same. @@ -227,7 +245,7 @@ The kernel release comes from `/proc/sys/kernel/osrelease`, or, when that file c - A profile's `evidence_sources` name a workflow file under `.github/workflows/` and a job in its `jobs:` mapping; for a real profile the validator checks that both exist and that there is at least one. It reads the workflow as text, not as YAML, with lines that end at `\n`, `\r\n`, or `\r`, as in YAML, but not at U+0085, U+2028, or U+2029: a job is a key of the block mapping under an unindented `jobs:` line (or `"jobs":`, `'jobs':`, or `jobs :`, with nothing after the colon but blanks and a comment after a blank), at the indentation of the mapping's first key, plain or quoted, with a colon followed by a space, a tab, or the end of the line. A `jobs` mapping written in flow style names no job, so such a profile fails with `evidence-source`. A synthetic profile is recorded by no job, so its sources are not checked. - A result names its profile in `profile_id`. With `--profile`, the validator checks that the IDs match, that a real profile's record was measured as a GitHub Actions run, that its workflow and job are one of the profile's evidence sources (the workflow named by `run.ci.workflow_ref`, which a GitHub Actions record must carry with `run.ci.repository`; the collector records the ref, the workflow ref, and the commit of a branch checked out by its name for any ref git allows in a branch or tag name unless it holds a character the validator refuses in any value or a byte that is not UTF-8, or, when the file system encoding is not UTF-8, a character that is not ASCII, or, for the commit, a part that ends with `.`, up to the schema's 200 characters for the ref and for the whole workflow ref, so a manual dispatch from a ref it cannot record fails `ci-evidence` and `profile-link`, and one whose commit alone it cannot record fails `ci-evidence`), and that every fact the profile declares, in its layers, runtimes, backends (matched by ID), accelerator inventory, and runner, is matched by an `observed` or `inferred` value in the result. A fact the profile leaves `unknown` is not compared, so a profile declares only what its job can observe; a fact it declares `not-applicable` must not be measured in the result. Facts inside `extensions` are not compared. - A profile may declare the runner its job uses in `runner` (`runner_environment`, `runner_os`, `runner_arch`, `image_os`), compared with the result's `run.ci` facts. `linux-x64-gha` declares a GitHub-hosted Linux X64 runner on the `ubuntu24` image, so a self-hosted record does not satisfy it. -- The CI job validates its record with `--require-real`, `--profile`, and `--dependency-strategy`, and uploads it as an artifact whether or not validation passed (see [TESTING.md](TESTING.md) "Continuous integration"). The collector does not write a record that holds a secret-shaped value, so the validator's `secret-like-value` check is a second check, not the gate before upload. +- Each CI job validates its record with `--require-real`, `--profile`, and `--dependency-strategy`, and the `platform-windows` and `abi-windows` jobs also with `--no-policy` (see "Policy"), and uploads it as an artifact whether or not validation passed (see [TESTING.md](TESTING.md) "Continuous integration"). The collector does not write a record that holds a secret-shaped value, so the validator's `secret-like-value` check is a second check, not the gate before upload. ### Commits @@ -240,6 +258,14 @@ Each profile's `support` field copies its status from the lane table; this list | Document | Kind | Lane named in `support` | Run by | |---|---|---|---| | `profiles/linux-x64-gha.json` | real profile, required stage `detect` | Linux x86-64 glibc | `compatibility` job in `.github/workflows/ci.yml` | +| `profiles/gha-linux-x64-cp312.json`, `gha-linux-arm64-cp312.json` | real, CPython 3.12.15 on `ubuntu-24.04` and the native Arm64 `ubuntu-24.04-arm`, required stages `detect` to `correctness` | Linux x86-64 glibc; Linux aarch64 glibc | `platform` job in `.github/workflows/compatibility.yml` | +| `profiles/gha-macos-arm64-cp312.json` | real, `macos-15`, required stages `detect` to `correctness` | macOS Apple Silicon (`not-assessed`: the row's statuses depend on the macOS version, which each run records and the profile does not declare) | `platform` | +| `profiles/gha-macos-x64-cp312.json` | real, `macos-15-intel`, required stage `detect` | macOS Intel | `platform` | +| `profiles/gha-windows-x64-cp312.json`, `gha-windows-arm64-cp312.json` | real, `windows-2025` and `windows-11-arm` with a native Arm64 Python, required stage `detect` | Windows x64, native; Windows on Arm, native (`win_arm64`) | `platform-windows` job in `compatibility.yml` | +| `profiles/gha-windows-arm64-x64-cp312.json` | real, an x64 Python and Node that Windows on Arm emulates, required stage `detect` | none (`not-assessed`: no row describes emulated x64 on Windows on Arm) | `platform-windows` | +| `profiles/gha-ubi9-x64-cp312.json`, `gha-ubi10-x64-cp312.json` | real, three layers (host, the `ubuntu-24.04` VM, a UBI 9.8 or 10.2 container in rootless Podman), required stages `detect` to `correctness` | Linux x86-64 glibc (UBI 9 `not-assessed`: its glibc 2.34 is below the row's bound) | `ubi` job in `compatibility.yml` | +| `profiles/gha-ubi9-arm64-cp312.json`, `gha-ubi10-arm64-cp312.json` | real, the same on `ubuntu-24.04-arm` | Linux aarch64 glibc | `ubi` | +| `profiles/gha--cp315.json`, `gha--cp315t.json` for `linux-x64`, `linux-arm64`, `windows-x64`, `windows-arm64`, `macos-arm64`, `macos-x64` (12) | real, CPython 3.15.0 with the GIL and the free-threaded 3.15.0, required stage `detect` | the runner's row (`not-assessed`: another interpreter than the lock's) | `abi` job in `.github/workflows/python-abi.yml`, or `abi-windows` for the Windows runners | | `fixtures/linux-x64-vm.*` | synthetic, CPU only | Linux x86-64 glibc (`not-assessed`, as a synthetic profile) | none | | `fixtures/windows-arm64-native.*` | synthetic, install failed, QNN NPU listed | Windows on Arm, native (`win_arm64`) | none | | `fixtures/windows-arm64-wsl2-podman.*` | synthetic, rootless Podman with a non-root user | Windows on Arm with WSL2 (Ubuntu arm64) | none | @@ -251,12 +277,16 @@ Each profile's `support` field copies its status from the lane table; this list A later change that adds an OS, ABI, accelerator, or runtime lane: 1. Adds a profile under `tests/compatibility/profiles/` that declares only what its job can observe, copies its support status from the lane table (or uses `not-assessed` for a lane the table does not list, or for an interpreter other than the lock's), names its `gpu` or `npu` backend in `accelerator_backend` if it is an accelerator lane, declares its runner, and names the job in `evidence_sources`. -2. Adds the job, which runs the collector with `--profile-id` and passes the stages its other steps ran with the `--stage*` options, citing their logs or artifacts and their privilege escalation, then runs the validator with `--require-real`, `--profile`, and `--dependency-strategy`. The collector step runs with `if: ${{ !cancelled() }}` after the workload steps and takes each status from that step's `steps..outcome`: `success` gives `passed`, `failure` gives `failed`, and `skipped` gives `blocked` or `not-run` with the reason, each citing that step's log. -3. Adds whatever probe the lane needs that the collector does not have, such as Windows or macOS facts (token elevation and privileges, the layer kind), engine-side container facts, or accelerator inventory, without per-technology validators. The `--stage*` options cannot record a stage's `compute`, and the collector lists only the `cpu` backend, so an accelerator lane also needs a way to record its backend and each stage's requested and effective backend before its profile can pass. Values outside the documented lists warn, and the rules that need a documented value skip them, until they are added to the lists; data without a field goes in `x-` extensions. A change that removes or narrows a field increments `schema_version`. The probe tests describe made-up Linux machines with the interpreter facts fixed to match each one, so they do not depend on the host's interpreter, but so far they have run only on Linux x86-64. +2. Adds the job, which runs the collector with `--profile-id` and passes the stages its other steps ran with the `--stage*` options, citing their logs or artifacts and their privilege escalation, then runs the validator with `--require-real`, `--profile`, and `--dependency-strategy`. The collector step runs with `if: ${{ !cancelled() }}` after the workload steps and takes each status from that step's `steps..outcome`: `success` gives `passed`, `failure` gives `failed`, and `skipped` gives `blocked` or `not-run` with the reason, each citing that step's log. The install stage takes its status from the wheel probe instead (see "Install stage in the lane jobs"). +3. Adds whatever probe the lane needs that the collector does not have, such as Windows token privileges beyond elevation and group membership, the engine-side facts of an engine other than Podman, or accelerator inventory, without per-technology validators. The `--stage*` options cannot record a stage's `compute`, and the collector lists only the `cpu` backend, so an accelerator lane also needs a way to record its backend and each stage's requested and effective backend before its profile can pass. Values outside the documented lists warn, and the rules that need a documented value skip them, until they are added to the lists; data without a field goes in `x-` extensions. A change that removes or narrows a field increments `schema_version`. The probe tests describe made-up machines, macOS and Windows among them, with the interpreter and native values fixed to match each one, so they do not depend on the host's interpreter; they are written for a Linux host and run on the Linux x86-64 and Arm64 lanes. 4. Changes a lane's support status only by editing [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md) with the evidence. ## What a passing job means -A passing compatibility job shows, for that commit and runner, that the collector ran there and wrote a record that is structurally valid, breaks no rule above, measures every fact the profile declares, including a GitHub-hosted runner, and passes `detect`; that the committed profiles validate against the lane table; and that the validator's and collector's tests passed, including the documents that must fail. +A passing `compatibility` job in `ci.yml` shows, for that commit and runner, that the collector ran there and wrote a record that is structurally valid, breaks no rule above, measures every fact the profile declares, including a GitHub-hosted runner, and passes `detect`; that the committed profiles validate against the lane table; and that the validator's and collector's tests passed, including the documents that must fail. It does not show that any other OS, architecture, Python or Node version, or accelerator works; that the application installs, starts, or behaves correctly on this lane (those stages are `not-run`, and `overall` is `incomplete`); that no step of the job escalated privileges (GitHub-hosted runners provide passwordless `sudo`, which this job does not use and the collector does not test, and membership in the `docker` group, which would also be root-equivalent, is not checked; `detect`'s `none` is declared by the collector); or that emulation was ruled out beyond the limits under "CPU execution". The job is not a required check, and a green run is not a review or an approval. + +A passing lane of `compatibility.yml` shows the same for its runner, plus what its record's stages say: for a stage that `passed`, that the hashed lock installed binary-only and `uv pip check` passed, that `manage.py check` and the migration check passed, that the Django tests ran and passed, and that pytest ran the same tests from the test lock; and, where the lane runs them, that the frontend lint, type check, build, and Vitest passed. A green lane can also hold a `blocked` install, which shows the lock cannot produce a working environment there and why; the profile's required stages decide whether that fails the lane. It does not show that the stages ran without an administrator token on Windows, that the lane's support status changed, or, for a UBI lane, anything about a RHEL host: the container shares the Ubuntu VM's kernel, has no SELinux in Enforcing mode, and runs no systemd, so a UBI pass is evidence about the image's user space with rootless Podman only. + +A passing lane of `python-abi.yml` shows that the interpreter was CPython 3.15.0 of the requested build, that the probe's tests passed on it, and, per package, which wheel was accepted and installed with the lock's hash and whether one import and one call passed in a fresh process, with the GIL state before and after. It does not show thread safety, which no step tests, nor that the backend works on 3.15: the lock is resolved for 3.12, Django is not run, and packages without a 3.15 wheel are not installed. diff --git a/docs/CODE-REVIEW.md b/docs/CODE-REVIEW.md index c031b01..dd1ba8b 100644 --- a/docs/CODE-REVIEW.md +++ b/docs/CODE-REVIEW.md @@ -7,7 +7,7 @@ It is not a security policy (see [SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTUR ## Status - In effect for every code change, starting at HEAD 8ec5623 (committed 2026-09-21; the repository facts in this document were verified on 2026-10-08). The procedure depends on nothing that is Planned. (Updated 2026-10-10: the Frontend boundary checks were re-checked at 14b9109; unchanged through 3fe4d1a.) -- Tooling: `.github/workflows/ci.yml` is the only workflow in this repository's files. It runs the baseline backend, frontend, and Playwright checks, plus the pytest and Vitest unit test jobs and the compatibility job of [CI-MATRIX.md](CI-MATRIX.md), on pull requests to `main` and pushes to `main`, on the checked lane only; [TESTING.md](TESTING.md) "Continuous integration" lists its jobs and what it does not cover. Its runs are evidence to read (see "Independence and authority" and step 8). GitHub-side integrations, such as review bots and required status checks, are configured outside the repository's files; whether one runs depends on the connected service and its settings, and its output is evidence to read. `.github/` also holds `copilot-instructions.md`, a bridge file that points to [CONTEXT.md](CONTEXT.md), and the advisory issue forms and pull request template described in [CONTEXT.md](CONTEXT.md) "GitHub issues and pull requests"; they are writing aids, not review tooling or evidence. Adding another workflow, or extending `ci.yml` beyond the checked lane, is a separate, explicitly requested change. (Updated 2026-10-11.) +- Tooling: `.github/workflows/ci.yml` runs the baseline backend, frontend, and Playwright checks, plus the pytest and Vitest unit test jobs and the compatibility job of [CI-MATRIX.md](CI-MATRIX.md), on pull requests to `main` and pushes to `main`, on the checked lane only. Since 2026-10-12, `.github/workflows/compatibility.yml` and `.github/workflows/python-abi.yml` run the backend and frontend checks on other GitHub-hosted runner lanes and in UBI containers, and probe CPython 3.15.0 and 3.15t, on pull requests to `main` that change their inputs and on manual dispatch; they are not required checks, and a pull request that changes none of their paths has no run of them. Their Windows jobs run only for a pull request from a branch of this repository or a manual run on `main`, so a fork's pull request has no Windows result ([SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token)). [TESTING.md](TESTING.md) "Continuous integration" lists the jobs and what they do not cover. Its runs are evidence to read (see "Independence and authority" and step 8). GitHub-side integrations, such as review bots and required status checks, are configured outside the repository's files; whether one runs depends on the connected service and its settings, and its output is evidence to read. `.github/` also holds `copilot-instructions.md`, a bridge file that points to [CONTEXT.md](CONTEXT.md), and the advisory issue forms and pull request template described in [CONTEXT.md](CONTEXT.md) "GitHub issues and pull requests"; they are writing aids, not review tooling or evidence. Adding another workflow, or extending `ci.yml` beyond the checked lane, is a separate, explicitly requested change. (Updated 2026-10-12.) - Verification baseline: the automated tests that exist are listed in [TESTING.md](TESTING.md) under "Tests that exist today". They were not executed in this documentation pass. "Exists" and "executed" stay distinct throughout this document. - The checks under "Boundary-specific checks" for Realtime, Task, and Runtime cover Planned areas. They apply to a change that introduces such code; they never imply the feature exists. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index d72d415..6d989f3 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -17,7 +17,7 @@ The primary collaboration direction is topic- and thread-based rather than chat- ## Current application scaffold - Frontend: Next.js user UI at `/`, product Console at `/console` (reserved as the `/console/*` surface; only the index page exists today), React, TypeScript, Tailwind CSS, axios, SweetAlert2, Node Playwright tests, and Vitest unit tests with React Testing Library. -- Backend: Django 6.1 using one Django project (`config`) with Django apps `core` and `agent`. The checked lane is CPython 3.12.15 on Linux x86-64. Other lanes, including Linux arm64, Windows on Arm, macOS, and Arm devices with or without CUDA, have static evidence only or are unsupported; see [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes) "Platform and accelerator lanes". Every other Python/ABI/platform combination needs separate qualification. +- Backend: Django 6.1 using one Django project (`config`) with Django apps `core` and `agent`. The checked lane is CPython 3.12.15 on Linux x86-64. Other lanes, including Linux arm64, Windows on Arm, macOS, and Arm devices with or without CUDA, are not checked: they have static evidence, some also CI observations that change no status, or are unsupported; see [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes) "Platform and accelerator lanes". Every other Python/ABI/platform combination needs separate qualification. - `/core/*`: Django REST Framework control-plane APIs; `/agent/*`: Agent FastAPI; `/admin/*`: Django Admin. - `config.asgi.application` composes Django and FastAPI and is served by both Daphne-backed `manage.py runserver` and direct Uvicorn. - `config/database.py` builds the Django database setting from `DATABASE_URL` (PostgreSQL only, strict parsing) and falls back to SQLite when it is unset or blank. diff --git a/docs/DEPENDENCY-STRATEGY.md b/docs/DEPENDENCY-STRATEGY.md index 9a9eeae..850a64d 100644 --- a/docs/DEPENDENCY-STRATEGY.md +++ b/docs/DEPENDENCY-STRATEGY.md @@ -2,13 +2,13 @@ ## Status and purpose -Baseline and strategy recorded on 2026-10-09. (Updated 2026-10-10: follow-up fixes in commits eab842f, 14b9109, 0f6a6ec, 3fe4d1a and be209aa with supplemental author-run checks, security and freshness floors, two further exceptions, a static platform and accelerator lane review, a sharper performance admission rule, and the Python 3.15.0 final release. A later run the same day tested the code at 53ac8c5 with the pinned Node/npm/uv/CPython toolchain and Playwright's paired browser; that closed the paired-browser exception, and the remaining exceptions are tracked in [#15](https://github.com/P-R-N-D/Claus-AI/issues/15). A later change the same day added a test-only pytest-django exception that only this document tracks.) The baseline below is installed and tested on the stated environment; the Python 3.15 sections record a qualification plan and dated upstream observations. This update does not change any `SEC-*` requirement status or establish production readiness. +Baseline and strategy recorded on 2026-10-09. (Updated 2026-10-10: follow-up fixes in commits eab842f, 14b9109, 0f6a6ec, 3fe4d1a and be209aa with supplemental author-run checks, security and freshness floors, two further exceptions, a static platform and accelerator lane review, a sharper performance admission rule, and the Python 3.15.0 final release. A later run the same day tested the code at 53ac8c5 with the pinned Node/npm/uv/CPython toolchain and Playwright's paired browser; that closed the paired-browser exception, and the remaining exceptions are tracked in [#15](https://github.com/P-R-N-D/Claus-AI/issues/15). A later change the same day added a test-only pytest-django exception that only this document tracks. Updated 2026-10-12: the first CI runs on the other runner lanes, in UBI containers, and with CPython 3.15.0 and 3.15t, recorded under [Observed lane results, 2026-10-12](#observed-lane-results-2026-10-12) without changing any lane status, and a third freshness batch.) The baseline below is installed and tested on the stated environment; the Python 3.15 sections record a qualification plan and dated upstream observations. This update does not change any `SEC-*` requirement status or establish production readiness. Free-threaded compatibility is already the architecture direction in [CONTEXT.md](CONTEXT.md#concurrency-direction) and [ARCHITECTURE.md](ARCHITECTURE.md#python-concurrency). This document makes that direction actionable for Python 3.15's **Limited API and `abi3t` wheels**. Explicit synchronization, isolation, and the GIL-enabled compatibility fallback remain in force; adopting a stable ABI does not replace them. The objective is the newest **supported, secure, reproducible combination** of packages, interpreter, ABI, and platform. A newer version is the first candidate, not permission to ignore its dependency constraints. Claus currently consumes third-party native extensions; it does not publish its own extension wheels. -The checked baseline is CPython 3.12.15 on Linux x86-64 with a hashed backend lock and a wheel inventory, plus Node 24.21.0/npm 11.21.0 with an npm lock. Other operating systems, CPU architectures and accelerators have only static evidence; see [Platform and accelerator lanes](#platform-and-accelerator-lanes). Neither Python 3.15 nor 3.15t is qualified. Since 2026-10-10, `.github/workflows/ci.yml` runs this baseline's checks on Linux x86-64 for pull requests and pushes to `main` ([TESTING.md](TESTING.md#continuous-integration)); automated dependency alerts, CI for any other lane, ABI qualification and performance benchmarks remain planned work. The compatibility and security exceptions below prevent treating this batch as an unconditional all-clear. +The checked baseline is CPython 3.12.15 on Linux x86-64 with a hashed backend lock and a wheel inventory, plus Node 24.21.0/npm 11.21.0 with an npm lock. Other operating systems and CPU architectures are not checked: their statuses rest on static evidence, and since 2026-10-12 also on CI observations that change no status; accelerators have only static evidence. See [Platform and accelerator lanes](#platform-and-accelerator-lanes). Neither Python 3.15 nor 3.15t is qualified. Since 2026-10-10, `.github/workflows/ci.yml` runs this baseline's checks on Linux x86-64 for pull requests and pushes to `main`, and since 2026-10-12 `.github/workflows/compatibility.yml` and `.github/workflows/python-abi.yml` run other lanes and CPython 3.15.0/3.15t on pull requests that change their inputs and on manual dispatch ([TESTING.md](TESTING.md#continuous-integration)); automated dependency alerts, ABI qualification and performance benchmarks remain planned work. The compatibility and security exceptions below prevent treating this batch as an unconditional all-clear. ## Version selection and adjustment @@ -68,6 +68,15 @@ Several pins were published within days of the update: pycparser 3.11 about an h | `@babel/runtime` | 7.29.10 (7.29.9) | 2026-10-07 | npm trusted publishing from `babel/babel` `release.yml` on the 7.x branch (manual dispatch, like 7.29.8 and 7.29.9) | Two helper changes from its changelog (`toPrimitive` guard, simpler `interopRequireWildcard`). Required by `@testing-library/dom` and `@testing-library/react`. | | tldts, tldts-core | 7.4.18 (7.4.17) | 2026-10-07 | npm trusted publishing from `remusao/tldts`, one run for both | No code change: version and build-info hashes only. 7.4.17's tag and `gitHead` commit are missing upstream and the changelog has no 7.4.18 entry; the attested 7.4.18 content matches the tagged source. Required by tough-cookie under jsdom. | +(Added 2026-10-12.) The third batch is what `.github/workflows/compatibility.yml` and `.github/workflows/python-abi.yml` add, selected on 2026-10-12. Most of it is the existing baseline on more platforms, not a new selection: the same pinned actions as `ci.yml`, uv 0.12.24, CPython 3.12.15 builds for the other operating systems and architectures (uv 0.12.24 checks each download against the SHA-256 in its own download metadata), Node 24.21.0 and npm 11.21.0, and the hashed backend and test locks. No package is added to a lock and no lock changes. The new selections, all younger than 7 days, are below. Neither workflow is part of the baseline that `ci.yml` checks, and neither selection reaches the backend or frontend lock. + +| Selection | Release | Published (UTC) | Publisher and provenance | Changes, and why this release | +|---|---|---|---|---| +| uv, in `python-abi.yml` only | 0.13.0 (0.12.24 stays in every other job) | 2026-10-09 19:49 | Astral (`astral-sh/uv` GitHub release). For all six runner platforms, the SHA-256 in the release's `.sha256` files, in `astral-sh/versions` `v1/uv.ndjson`, and in the checksums embedded in setup-uv v10.3.0 agree, and setup-uv checks the download against its embedded value. GitHub artifact attestations were not checked. | From its changelog at the 0.13.0 tag: CPython 3.15.0 is added and becomes the default when no version is requested; `--require-hashes` in an included constraints file is honored and editable requirements there are rejected; native Arm64 Python is preferred on Windows on Arm; new environments on Python 3.10 or later omit the distutils startup patch; requirement-file option values are single paths; tar archives use `tar-codec`; cache entries change format. The workflow names each interpreter in full (`cpython-3.15.0[+freethreaded]---`) and installs only hashed wheels, without constraints files, source distributions or configuration files, so of these changes only the omitted startup patch reaches it, in the environments it creates. Chosen because it is the first uv with CPython 3.15.0 final; uv 0.12.24 offers 3.15.0rc3 at most, and a release candidate is not the release this lane probes. | +| CPython 3.15.0 builds, with the GIL and free-threaded | python-build-standalone 20261009, for example `Python 3.15.0 (main, Oct 9 2026, 15:24:40) [Clang 22.1.3]` on Linux | built 2026-10-09, by the build string; the release's publish time was not checked | Astral (`astral-sh/python-build-standalone`); uv 0.13.0 names each build's URL and SHA-256 in its download metadata and checks the download. The builds' own attestations were not checked. | CPython 3.15.0 final (released 2026-10-09; see the Python 3.15 section below). Only `python-abi.yml` uses them, in throwaway environments. | +| `registry.access.redhat.com/ubi9/ubi` | `9.8-1791765424`, index digest `sha256:866c52a4f649b2e027e0eafeede5f37dd13ac58ab63bbe2950d7928334312a4c` | build date 2026-10-12 00:38, pushed 01:15 | Red Hat. Pinned by the multi-architecture index digest, which fixes the content selected, not who built it; Red Hat's image signatures were not verified. The Red Hat catalog (checked 2026-10-12) lists the image under bug-fix advisory RHBA-2026:80529 with freshness grade A for amd64, arm64, ppc64le and s390x. | The current UBI 9 image at selection. The advisory's package changes were not reviewed. The lane checks that the hashed backend lock installs and its tests pass in a UBI 9 user space under rootless Podman; it does not use the image's system Python. A later image is a new selection with this review. | +| `registry.access.redhat.com/ubi10/ubi` | `10.2-1791444044`, index digest `sha256:27e14f4987d7abe56664d7e1b1dddcd0226d4ca593da5726f0f65697a17d0fee` | build date 2026-10-08 07:23, pushed 08:33 | Red Hat, as above: advisory RHBA-2026:79104, freshness grade A for the same four architectures; signatures not verified. | The current UBI 10 image at selection, for the same check in a UBI 10 user space. The advisory's package changes were not reviewed. | + ### Reproducibility - Keep npm's manifest and lock in the same update and verify with `npm ci` in a fresh environment. Review resolved URLs, integrity values, lifecycle scripts, and unexpected graph changes. @@ -166,7 +175,7 @@ The lock's registry URLs and integrity records were inspected. The only dependen ## Platform and accelerator lanes -Recorded 2026-10-10. Only Linux x86-64 glibc has been executed. Every other lane below is **static evidence**: nothing was installed or run on those platforms, and an entry is not a support claim. +Recorded 2026-10-10, when only Linux x86-64 glibc had been executed: every other lane below is **static evidence**, and an entry is not a support claim. (Updated 2026-10-12: CI has since installed and tested the backend lock on other GitHub-hosted runners and in UBI containers. [Observed lane results, 2026-10-12](#observed-lane-results-2026-10-12) records those runs; they agree with the statuses below and change none of them.) Method: a script compared every pin's hashed artifacts in [the backend lock](../backend/locks/cp312-linux-x86_64.txt) with PyPI file metadata and evaluated environment markers for each target. The frontend column records whether the npm lock contains the platform-specific optional binaries; the browser column records upstream Playwright support. @@ -212,6 +221,41 @@ Sources checked on 2026-10-10: - [Linux on Snapdragon X2 Series early developer preview](https://www.qualcomm.com/developer/blog/2026/09/announcing-linux-on-snapdragon-x2-series-early-developer-preview) and [Qualcomm OnQ: Snapdragon Summit 2026](https://www.qualcomm.com/news/onq/2026/09/snapdragon-summit-agentic-ai-pcs-linux) (Debian by the end of 2026; Ubuntu certification targeted for the first half of 2027). - [Playwright for Python system requirements](https://playwright.dev/python/docs/intro) and [microsoft/playwright#40202](https://github.com/microsoft/playwright/issues/40202). +## Observed lane results, 2026-10-12 + +Added 2026-10-12. The first runs of `.github/workflows/compatibility.yml` (run 38210201779, 11 jobs) and `.github/workflows/python-abi.yml` (run 38210201628, 12 jobs), both green, on pull request [#19](https://github.com/P-R-N-D/Claus-AI/pull/19) at head `f7ccd3d`, tested as merge commit `aaaa80c1` onto `main` at `5f27b1c`. Each job's result record and probe reports are artifacts of its run, kept for 90 days. A run is evidence about one commit on one runner image; the lane table above remains the only record of status, and these runs change no status in it. Promoting a status, for example Linux aarch64 glibc to Checked, is a separate edit to that table. Every record is `incomplete` overall, because no lane runs the security and performance stages. + +Runner images: `ubuntu24` 20261004.327.1 (x86-64) and `ubuntu24-arm64` 20261004.142.1, both Ubuntu 24.04 with glibc 2.39 and kernel 6.17.0-1022-azure on Hyper-V; `macos15` 20260907.0337.1 (Apple Silicon, Apple Virtualization) and 20260824.0482.1 (Intel, hypervisor not identified), both macOS 15.7.9; `win25-vs2026` 20260925.250.1 (Windows 10.0.26100) and `win11-vs2026-arm64` 20261004.176.1 (Windows 10.0.26200), both on Hyper-V. Runs on Linux and macOS used a non-root user (uid 1001 and 501); on Windows the runner's user holds an elevated administrator token, which the record shows as `superuser` true, so those records are validated without the privilege policy and are no non-root result. Since a later commit of the same pull request, the Windows lanes run in separate jobs under a temporary exception, only for this repository's pull requests and manual runs on `main`; a Windows run without an administrator token is a follow-up ([SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token)). + +CPython 3.12.15 with the hashed backend and test locks (`install` from `uv pip sync --require-hashes --only-binary :all:` and `uv pip check`; `initialize` is `manage.py check` and `makemigrations --check --dry-run`; `execute` the 23 Django tests; `correctness` pytest with the test lock and the runner parity check). The frontend column is `npm ci` with npm 11.21.0, then lint, type check, build and the Vitest unit tests; it is a job step, not a recorded stage, and these runs' records do not hold it; later runs keep the outcomes of its two steps under `x-frontend`. + +| Lane, runner | Install | Initialize to correctness | Frontend | Lane row and its status above | +|---|---|---|---|---| +| `ubuntu-24.04` | passed | passed | passed | Linux x86-64 glibc, Checked; the run is inside its bounds | +| `ubuntu-24.04-arm`, native Arm64 | passed | passed | passed | Linux aarch64 glibc, statically resolvable | +| `macos-15` | passed | passed | passed | macOS Apple Silicon, statically resolvable for macOS 15 or newer | +| `macos-15-intel` | blocked: no wheel this interpreter can install for autobahn, cbor2 and cryptography | blocked | passed | macOS Intel, not binary-installable, for the same three packages | +| `windows-2025` | blocked: the lock does not pin `tzdata`, which django and psycopg require on Windows | blocked | passed | Windows x64, native, incomplete for the same reason | +| `windows-11-arm`, native Arm64 Python and Node | blocked: no wheel for autobahn, cryptography and psycopg-binary; the install stops there, so the `tzdata` gap was not reached | blocked | passed | Windows on Arm, native, not supported | +| `windows-11-arm`, x64 Python and Node under emulation | blocked: `tzdata`, as on Windows x64 | blocked | not run | No row describes this lane | +| UBI 9.8 container on `ubuntu-24.04` | passed | passed | not run | Linux x86-64 glibc, but its glibc 2.34 is below the Checked bound | +| UBI 10.2 container on `ubuntu-24.04` | passed | passed | not run | Linux x86-64 glibc, Checked; glibc 2.39 | +| UBI 9.8 and 10.2 containers on `ubuntu-24.04-arm` | passed | passed | not run | Linux aarch64 glibc, statically resolvable | + +Every blocked stage above is a prerequisite the lock lacks, which the probe named from the installer's failure and the lock's hashed wheels; an installer failure it could not explain would have been `failed`. On the four UBI lanes, Podman reported itself rootless (`podman info`, `host.security.rootless`), and the container was not privileged and had no devices; inside it, the workload ran as uid 1001 mapped to the runner's user (`uid_map` 0 1 1001, 1001 0 1, 1002 1002 64535), with `no_new_privs`, no effective capabilities, a seccomp filter, and SELinux reported disabled. glibc was 2.34 in UBI 9.8 and 2.39 in UBI 10.2. The images' own Python (3.9.25 in UBI 9.8, 3.12.15 in UBI 10.2) was recorded and not used; every lane used uv's CPython 3.12.15 build. A UBI lane is a UBI user space on an Ubuntu kernel: it is not a RHEL host, SELinux enforcing, or a RHEL kernel result. + +CPython 3.15.0 and its free-threaded build (3.15t) from uv 0.13.0, on the six runners, against the same Linux-resolved lock (each package installed binary-only with the lock's hashes, with only the lock packages it needs, then imported and called once in a fresh `python -I` process, so `PYTHON_GIL` from the environment does not apply). In every job `install` was blocked, because at least one package has no wheel for the interpreter, and the probe's import and call of every package that installed passed (in `x-python-abi`; the record's `execute` stage is `not-run`, since the backend does not run). `Py_GIL_DISABLED` was 0 in every 3.15 build and 1 in every 3.15t build, and `sys._is_gil_enabled()` was true and false respectively, before the probe and after every import and call: no package re-enabled the GIL, and none emitted a warning. (Since then a re-enablement fails the job; in these runs it would only have been recorded.) Extension suffixes on Linux and macOS include `.abi3.so` and `.abi3t.so` in the 3.15 build and `.abi3t.so` without `.abi3.so` in the 3.15t build; on Windows they are `.cp315-win_.pyd` (or `cp315t`) and `.pyd`. + +| Package, lock version | Installed, imported and called | Not installable | +|---|---|---| +| cffi 2.1.1, greenlet 3.5.6, msgpack 1.2.3, pydantic-core 2.50.0, ujson 6.0.0 | On all 12 interpreters, from their `cp315-cp315` and `cp315-cp315t` wheels | None | +| cbor2 6.1.5 | `cp315-cp315` and `cp315-cp315t` wheels on Linux (manylinux_2_28), macOS Apple Silicon and Windows x64 and Arm64 | macOS Intel: no wheel for either build | +| cryptography 50.0.2 | The `cp315-abi3.abi3t` wheel on both builds: `manylinux_2_34` x86-64 and aarch64, `macosx_11_0_arm64`, `win_amd64`. On the 3.15 build the lock's `cp311-abi3` and `cp39-abi3` wheels are also accepted; uv chose the `cp315-abi3.abi3t` one | macOS Intel and Windows Arm64: no wheel for either build | +| psycopg-binary 3.3.6 | `cp315-cp315` wheels on Linux x86-64 and aarch64 and on macOS | 3.15t: no `cp315t` or `abi3t` wheel on any platform. Windows Arm64: no wheel. Windows x64 3.15: the `cp315-cp315` wheel exists, but `psycopg` needs `tzdata`, which the lock does not pin | +| autobahn 26.7.1, zope-interface 8.6 | None | No `cp315`, `cp315t` or stable-ABI wheel on any platform | + +These results establish wheel availability, installation, and one import and call per package from one thread on each interpreter. They do not establish 3.15 or 3.15t support: Django, Daphne and the application were not imported, because the lock does not install, and **thread safety was not tested**. Each job's `python-abi.json` artifact holds, per package, which of `cp315-cp315`, `cp315-cp315t`, `cp315-abi3`, `cp315-abi3t` and `cp315-abi3.abi3t` PyPI publishes for the locked version and which the interpreter accepts, and the selected filename and SHA-256. + ## Python 3.15: Limited API and wheel contract The Limited API is the source-level C API subset; the Stable ABI is the resulting binary compatibility contract. Python 3.15 introduces `abi3t` through [PEP 803](https://peps.python.org/pep-0803/). It reduces rebuilds across future Python minor versions; it does not make arbitrary existing binaries thread-safe or portable across operating systems. @@ -252,7 +296,7 @@ Wheel publication checked through version-specific PyPI JSON endpoints on 2026-1 For example, PyPI publishes `cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl`. This is the intended stable-ABI opportunity, rather than simply installing all `cp315t` wheels. Platform coverage and hashes must be recorded per artifact. -At the 2026-10-09 snapshot, [PEP 790](https://peps.python.org/pep-0790/) listed 2026-10-09 as the **expected** 3.15.0 final date, and the checked CPython branch still identified itself as `3.15.0rc3+dev`. (Updated 2026-10-10.) Python 3.15.0 final was released on 2026-10-09 ([release page](https://www.python.org/downloads/release/python-3150/), [What's New in 3.15](https://docs.python.org/3.15/whatsnew/3.15.html)); PEP 790 now lists that date under its actual schedule. The final release does not remove the blockers above. Rechecked on 2026-10-10: psycopg-binary 3.3.6, still the newest release, has `cp315-cp315` wheels but no `cp315t` or `abi3t` wheel; Autobahn 26.7.1 and zope.interface 8.6, also the newest releases, have no 3.15 or stable-ABI wheel; and Django 6.1's support table still lists only 3.12, 3.13 and 3.14. The other wheel observations above predate the final release and must be re-queried. Prerelease ABI experiments do not establish final-release support. +At the 2026-10-09 snapshot, [PEP 790](https://peps.python.org/pep-0790/) listed 2026-10-09 as the **expected** 3.15.0 final date, and the checked CPython branch still identified itself as `3.15.0rc3+dev`. (Updated 2026-10-10.) Python 3.15.0 final was released on 2026-10-09 ([release page](https://www.python.org/downloads/release/python-3150/), [What's New in 3.15](https://docs.python.org/3.15/whatsnew/3.15.html)); PEP 790 now lists that date under its actual schedule. The final release does not remove the blockers above. Rechecked on 2026-10-10: psycopg-binary 3.3.6, still the newest release, has `cp315-cp315` wheels but no `cp315t` or `abi3t` wheel; Autobahn 26.7.1 and zope.interface 8.6, also the newest releases, have no 3.15 or stable-ABI wheel; and Django 6.1's support table still lists only 3.12, 3.13 and 3.14. The other wheel observations above predate the final release and must be re-queried. Prerelease ABI experiments do not establish final-release support. (Updated 2026-10-12.) `.github/workflows/python-abi.yml` re-queried every native package's wheels for its locked version on PyPI and installed, imported and called each one it could on CPython 3.15.0 and 3.15t, with uv 0.13.0, on six runner platforms. The observations above held, and cbor2, cryptography and psycopg-binary also lack wheels on some platforms; see [Observed lane results, 2026-10-12](#observed-lane-results-2026-10-12). Those runs are not a qualification: the lock did not install on any of them, and Django's 3.15 support was not rechecked. ## Packaging and build qualification diff --git a/docs/README.en.md b/docs/README.en.md index 18d2866..b2a6d29 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -53,14 +53,14 @@ Technical documents for AI coding agents and contributors are written in English ## Local development -Only Linux x86-64 has been run. The other rows are static evidence from [Platform and accelerator lanes](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes), not support claims. +Only Linux x86-64 is a checked lane. The other rows are static evidence from [Platform and accelerator lanes](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes), not support claims. Since 2026-10-12, CI also runs the backend and frontend checks on GitHub-hosted Linux arm64, macOS 15, and native Windows runners; those runs change no row ([Observed lane results](DEPENDENCY-STRATEGY.md#observed-lane-results-2026-10-12)). | Machine | How to run the steps below | |---|---| | Linux x86-64 | Use the backend lock as shown (the checked lane). | -| Linux arm64 (glibc), including NVIDIA DGX Spark | For local development only, use the backend lock as shown. It is statically resolvable on arm64 but has not been run there; qualifying this platform needs its own separately named lock ([backend locks](../backend/locks/README.md)). | +| Linux arm64 (glibc), including NVIDIA DGX Spark | For local development only, use the backend lock as shown. It is statically resolvable on arm64, and the backend tests passed there in CI on a GitHub-hosted runner, which is not a status change; qualifying this platform needs its own separately named lock ([backend locks](../backend/locks/README.md)). | | Windows x64, and Windows on Arm such as NVIDIA RTX Spark or Qualcomm Snapdragon PCs | Run the steps in WSL2 (Ubuntu). Native Windows cannot produce a working environment from this Linux-resolved lock: on Windows x64 it installs without `tzdata`, which Django and psycopg require on Windows, and on Windows on Arm the install fails because `autobahn`, `cryptography`, and `psycopg-binary` have no `win_arm64` wheels. On native Windows on Arm, Playwright also runs x64 browsers under emulation. | -| macOS 15 or later on Apple Silicon | For local development only, use the backend lock as shown. It is statically resolvable but has not been run; qualifying this platform needs its own separately named lock. | +| macOS 15 or later on Apple Silicon | For local development only, use the backend lock as shown. It is statically resolvable, and the backend tests passed in CI on a GitHub-hosted macOS 15 runner, which is not a status change; qualifying this platform needs its own separately named lock. | | macOS 14 or older, Intel Macs, Alpine (musl) | Not binary-installable with this lock. | CUDA is not required. The current dependency graph contains no CUDA or other accelerator-specific package, so the same steps apply with or without an NVIDIA GPU, including Arm machines without CUDA such as Snapdragon PCs. diff --git a/docs/README.ko.md b/docs/README.ko.md index 179e54e..d95b47c 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -53,14 +53,14 @@ AI 코딩 에이전트와 기여자를 위한 기술 문서는 영어로 작성 ## 로컬 실행 순서 -실제로 실행해 본 환경은 Linux x86-64뿐입니다. 나머지 행은 [플랫폼 및 가속기 lane](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes)의 정적 근거이며 지원을 보장한다는 뜻이 아닙니다. +검증된 lane은 Linux x86-64뿐입니다. 나머지 행은 [플랫폼 및 가속기 lane](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes)의 정적 근거이며 지원을 보장한다는 뜻이 아닙니다. 2026-10-12부터 CI가 GitHub 호스팅 Linux arm64, macOS 15, 네이티브 Windows 러너에서도 backend와 frontend 검사를 실행하지만, 그 결과로 바뀌는 행은 없습니다([관찰된 lane 결과](DEPENDENCY-STRATEGY.md#observed-lane-results-2026-10-12)). | 머신 | 아래 단계 실행 방법 | |---|---| | Linux x86-64 | 표시된 대로 backend 잠금 파일을 사용합니다(검증된 lane). | -| Linux arm64(glibc), NVIDIA DGX Spark 포함 | 로컬 개발 용도로만 표시된 대로 backend 잠금 파일을 사용합니다. arm64에서 정적으로 해석 가능하지만 실제로 실행해 보지는 않았으며, 이 플랫폼을 검증하려면 별도 이름의 잠금 파일이 필요합니다([backend locks](../backend/locks/README.md)). | +| Linux arm64(glibc), NVIDIA DGX Spark 포함 | 로컬 개발 용도로만 표시된 대로 backend 잠금 파일을 사용합니다. arm64에서 정적으로 해석 가능하고 GitHub 호스팅 러너의 CI에서 backend 테스트가 통과했지만 상태가 바뀐 것은 아니며, 이 플랫폼을 검증하려면 별도 이름의 잠금 파일이 필요합니다([backend locks](../backend/locks/README.md)). | | Windows x64, 그리고 NVIDIA RTX Spark나 Qualcomm Snapdragon PC 같은 Windows on Arm | WSL2(Ubuntu)에서 실행합니다. 네이티브 Windows에서는 Linux 기준으로 해석한 이 잠금 파일로 동작하는 환경을 만들 수 없습니다. Windows x64에서는 Windows에서 Django와 psycopg가 요구하는 `tzdata` 없이 설치되고, Windows on Arm에서는 `autobahn`, `cryptography`, `psycopg-binary`에 `win_arm64` wheel이 없어 설치가 실패합니다. 네이티브 Windows on Arm에서는 Playwright도 x64 브라우저를 에뮬레이션으로 실행합니다. | -| Apple Silicon의 macOS 15 이상 | 로컬 개발 용도로만 표시된 대로 backend 잠금 파일을 사용합니다. 정적으로 해석 가능하지만 실제로 실행해 보지는 않았으며, 이 플랫폼을 검증하려면 별도 이름의 잠금 파일이 필요합니다. | +| Apple Silicon의 macOS 15 이상 | 로컬 개발 용도로만 표시된 대로 backend 잠금 파일을 사용합니다. 정적으로 해석 가능하고 GitHub 호스팅 macOS 15 러너의 CI에서 backend 테스트가 통과했지만 상태가 바뀐 것은 아니며, 이 플랫폼을 검증하려면 별도 이름의 잠금 파일이 필요합니다. | | macOS 14 이하, Intel Mac, Alpine(musl) | 이 잠금 파일로는 바이너리 설치가 불가능합니다. | CUDA는 필요하지 않습니다. 현재 의존성 그래프에는 CUDA나 다른 가속기 전용 패키지가 없으므로 NVIDIA GPU가 있든 없든, Snapdragon PC처럼 CUDA가 없는 Arm 머신에서도 같은 단계를 따릅니다. diff --git a/docs/SECURITY-ARCHITECTURE.md b/docs/SECURITY-ARCHITECTURE.md index 5ca8828..7a304a0 100644 --- a/docs/SECURITY-ARCHITECTURE.md +++ b/docs/SECURITY-ARCHITECTURE.md @@ -136,7 +136,7 @@ Verified on 2026-10-08. (Updated 2026-10-10: the `/core` tests, ASGI composition - `.gitignore` ignores `.env` and `.env.*` except example files (lines 30-33), private key files (lines 36-43), credential-shaped JSON (lines 46-53), logs (lines 64-65), `backend/db.sqlite3` (line 26), and browser profile directories (lines 83-88). - `.env.example` (lines 2-13) holds empty placeholders for `DJANGO_SECRET_KEY`, `DATABASE_URL`, and the five storage variables, plus `DJANGO_DEBUG=true`. Lines 15-20 hold two empty, non-secret, test-only Playwright variables, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` (line 18) and `PLAYWRIGHT_NEXT_SERVER` (line 20), with comments stating that Playwright does not load the file. (Updated 2026-10-10.) - The ignore rules only keep untracked files out of a commit. They do not apply to a file that is already tracked or that is added with `git add -f`, and they do not catch a secret written into source code, documentation, tests, or configuration, or one already in the history. -- No Git hook or secret scanner is configured in the repository's files. The one workflow, `.github/workflows/ci.yml`, runs build and test checks and scans nothing for secrets, apart from the compatibility collector's and validator's checks of their own records (see "CI workflow" below); `.github/` also holds `copilot-instructions.md` and advisory issue and pull request templates, none of which runs a check. (Updated 2026-10-11.) [SECURITY-REVIEW.md](SECURITY-REVIEW.md) step 7 defines a keyword check of a change's added lines, which a reviewer runs; nothing runs or enforces it automatically. +- No Git hook or secret scanner is configured in the repository's files. The workflows, `.github/workflows/ci.yml` and, since 2026-10-12, `compatibility.yml` and `python-abi.yml`, run build, test and compatibility checks and scan nothing for secrets, apart from the compatibility collector's and validator's checks of their own records (see "CI workflow" below); `.github/` also holds `copilot-instructions.md` and advisory issue and pull request templates, none of which runs a check. (Updated 2026-10-11.) [SECURITY-REVIEW.md](SECURITY-REVIEW.md) step 7 defines a keyword check of a change's added lines, which a reviewer runs; nothing runs or enforces it automatically. ### Frontend @@ -157,6 +157,32 @@ Added 2026-10-10; the compatibility job was added 2026-10-11. The workflow runs - The compatibility job (added 2026-10-11, lines 217-271) downloads uv and CPython 3.12.15 as the pytest job does, and Node from `.nvmrc` through `actions/setup-node` as the frontend jobs do, with `UV_NO_CONFIG` set (line 226) and `python-version` given to `astral-sh/setup-uv` (line 236), installs no package, and runs only the standard-library scripts `scripts/ci/collect_environment.py` and `scripts/ci/validate_compatibility.py` and their tests in `scripts/ci/tests/`, each with `python -I`. The collector reads `/proc`, `/sys`, the OS release file, a few files under `/run`, the interpreter's ELF header, the checkout's `.git/HEAD` and the ref it points to, a fixed allowlist of environment variables (never the whole environment), and only the pull request's head and base SHAs from the event file, and runs `node -p`, found in the `PATH` directories only (on Windows only as `node.exe`, whatever `PATHEXT` lists, and not in the current directory unless `PATH` names it, also when `PATH` is unset), with an empty environment (on Windows, `SystemRoot` only), reading at most 4 KiB of its output and stopping it, with its process group, after 30 seconds (a descendant that starts a session of its own can hold the read until the job times out; [CI-MATRIX.md](CI-MATRIX.md) "Collector" lists every source); it reads only regular files, so a FIFO cannot block it; it records no host name, account name, home directory, or network address, uses no network, and does not use the runner's `sudo`. Its record and summary describe the runner, not the repository's users. The collector refuses stage reasons and evidence shaped like common tokens or private keys and does not write a record that holds such a value, and the validator rejects records with control characters or such values (`secret-like-value`); in the lines both scripts print, their command-line errors included, and in the validator's summary, `##[` becomes `## [`, `::` becomes `: :`, and a secret-shaped value is not shown, so neither a record nor a file name can start a workflow command or show such a value in the log; these checks cover compatibility records only, scan nothing else, and do not stop a pull request that changes the collector, the validator, or their tests, which then run as changed, the same limit as the lock check above. - The E2E jobs upload `frontend/test-results/` as a run artifact kept for 7 days (lines 138-143), and the compatibility job uploads its record whether or not validation passed, kept for 90 days (lines 264-271). Logs and artifacts of runs in this public repository can be read by people outside the project, so a workflow step must not print secrets or personal data. +### Compatibility and Python ABI workflows (`.github/workflows/compatibility.yml`, `.github/workflows/python-abi.yml`) + +Added 2026-10-12. Line numbers below are `compatibility.yml`'s unless `python-abi.yml` is named. [TESTING.md](TESTING.md) "Compatibility and Python ABI workflows" lists what they run; neither is a required check. + +- Triggers: `pull_request` for `main`, limited to paths these workflows read (lines 10-22; `python-abi.yml` lines 9-19), and `workflow_dispatch`. There is no `push`, `pull_request_target`, or `workflow_run` trigger, so a fork's pull request runs with the read-only token GitHub gives it and without the repository's secrets, as in `ci.yml`. The token is limited to `contents: read` (lines 24-25; `python-abi.yml` lines 21-22), and no step references a secret or requests an OIDC token. Each run has a concurrency group per pull request that cancels the older run (lines 27-30). The Windows jobs, `platform-windows` and `abi-windows`, run only for a pull request from a branch of this repository and for a manual run on `main`, under the exception in "Temporary exception: Windows administrator token" below; a fork's pull request skips them unless it changes their condition, which that section covers. +- Runners: GitHub-hosted `ubuntu-24.04`, `ubuntu-24.04-arm`, `windows-2025`, `windows-11-arm`, `macos-15`, and `macos-15-intel`, by fixed label (lines 51-66, 291-302, 326-341; `python-abi.yml` lines 46-55, 195-198). No self-hosted, larger, or GPU runner is referenced. Jobs time out after 40 minutes (`python-abi.yml`: 30) and run at most four at a time per job matrix (lines 44, 47, 284, 287, 317, 320; `python-abi.yml` lines 38, 41, 187, 190). +- Every action is one of the four that `ci.yml` pins, at the same full commit SHA (lines 79-92, 264, 355-362, 545; `python-abi.yml` lines 66-76, 167); `platform-windows` and `abi-windows` run the same steps as `platform` and `abi` through YAML aliases (lines 306-307; `python-abi.yml` lines 200-201). Every checkout sets `persist-credentials: false`. setup-uv and setup-node run with their caches off (lines 85, 92, 361; `python-abi.yml` line 74), so one run cannot seed a cache that a later run restores, and every job sets `UV_NO_CONFIG` and gives setup-uv a `python-version`, as the `ci.yml` pytest job does. +- What a job downloads at run time: uv 0.12.24, or in `python-abi.yml` uv 0.13.0, checked by setup-uv against its embedded SHA-256; uv's CPython builds, which uv checks against its own download metadata; the hash-locked backend and test lock packages, installed binary-only; Node from `.nvmrc` through setup-node; npm 11.21.0 by version without a hash (line 155, as in `ci.yml`) and the npm lock's packages; and, on the UBI lanes, two Red Hat UBI images pulled by digest (lines 327-342, 403). The new uv, CPython 3.15.0 and image selections have a freshness and provenance review in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#security-floors-and-release-freshness); the image signatures were not verified. `scripts/ci/probe_python_abi.py` also reads `https://pypi.org/pypi///json` for each package the backend lock pins with Python's `urllib` (HTTPS, 30-second timeout, three attempts, at most 16 MiB), and uses that response only as data: wheel file names and tags for its report. It installs nothing from those URLs: in `python-abi.yml` it runs `uv pip install --no-config --default-index https://pypi.org/simple --require-hashes --only-binary :all: --no-deps` on a requirements file holding the lock's own lines and hashes, without a shell, with no input, and with a 600-second timeout, then imports and calls each installed package once in a fresh `python -I` process, which fails the package when it exits with a nonzero status or a signal, even after printing a passing result (`scripts/ci/probe_python_abi.py` lines 545-596). Both subprocesses inherit the job's environment, which holds no secret. +- On Windows runners the job's user holds an elevated administrator token. The collector records that as `superuser` true. Only `platform-windows` and `abi-windows` run on Windows, and only there does the validator run with `--no-policy`, reporting `policy-skipped`, so such a record is never a non-administrator result; "Temporary exception: Windows administrator token" below gives the scope, risks, approval, and end conditions. A step at the start of the shared steps fails a Windows runner in any other job (line 96; `python-abi.yml` line 80). +- UBI lanes (lines 309-551) require rootless Podman: the job fails, recording why, when `podman info` does not report rootless for the runner's user, and never falls back to Docker, a rootful engine, or `sudo` (lines 371-399). The container runs as the runner's user mapped into it (`--userns=keep-id --user "$(id -u):$(id -g)"`), with `--cap-drop=all` and `--security-opt=no-new-privileges`, not `--privileged`, with no `--device` and no host network or PID namespace option (lines 413-422). Mounts: the checkout read-write at `/workspace` (the job's own copy, which the pull request's code already controls), the records directory read-write, the setup-uv directory read-only, and a copy of the event file read-only. Environment: the fixed `GITHUB_*` and runner variables the collector reads, `HOME=/tmp`, `UV_NO_CONFIG`, uv's cache and Python directories under `/tmp`, and a fixed `PATH`; no token or secret is passed. Once the start step has run, the container is removed with `podman rm --force --ignore` whether or not later steps passed (lines 542-544). The image's system Python is run once with `-I` to record its version and is not used otherwise. +- The collector's `--amend-container` mode, run on the host after the container's own record is written (line 531), runs `podman info` and `podman container inspect ` without a shell and with no input, inheriting the job's environment, with a 120-second timeout, refusing either output when it is larger than 8 MiB (checked after the output is read in full), and adds only the engine mode, the privileged flag, and the device list to the record; it fails with exit 2, writing nothing, when the record or Podman's output is not what it expects. On macOS and Windows the collector also reads `sysctl` values and Windows registry and token information through `ctypes` and `winreg` without starting a process; [CI-MATRIX.md](CI-MATRIX.md) "Collector" lists every source. As in `ci.yml`, it records no host name, account name, home directory, or network address. +- Artifacts: each job uploads its result record, probe reports, and logs whether or not validation passed, kept for 90 days (lines 264-271, 545-551; `python-abi.yml` lines 167-174). They describe the runner and the lock's public packages, not the repository's users; the collector's and validator's secret-shaped value checks apply to the records, as in `ci.yml`, and nothing else is scanned. +- The limits of the `ci.yml` checks apply here too: a pull request that changes these workflows, the probes, the validator, or their tests runs them as changed, so those changes need review in the diff. + +### Temporary exception: Windows administrator token + +Approved 2026-10-12 by the repository owner for pull request #19 (PR C), which adds the two jobs below. It is a temporary security exception for the functional and ABI checks those jobs run on GitHub-hosted Windows runners. It covers every run of those jobs that meets their condition, before and after that pull request is merged, until an end condition below is met. It does not mean that a run without administrator rights was verified, and it does not relax the default privilege policy ([CI-MATRIX.md](CI-MATRIX.md) "Policy"), which every other job and record is still held to. + +- Scope: the `platform-windows` job of `compatibility.yml` (lines 273-307) and the `abi-windows` job of `python-abi.yml` (lines 176-201), on the `windows-2025` and `windows-11-arm` runners, where the job's user holds an elevated administrator token and GitHub offers no other. Only in these jobs, and only on a Windows runner, does the validator run with `--no-policy` (lines 243-253; `python-abi.yml` lines 153-163), which reports `policy-skipped`. Each record they write holds `x-policy-exception` (policy `skipped`, security `not-verified`, compatibility `incomplete`, non-administrator run `not-run`), and its `security` stage is `not-run` with a reason that says the same (lines 213-220; `python-abi.yml` lines 137-144); the step summary and a warning annotation repeat it. The record's `overall` is never `passed`: it is `incomplete`, or `failed` when a stage failed. The fixed value compatibility `incomplete` in `x-policy-exception` says only that the record is not a complete compatibility result. +- When the jobs run: for a `pull_request` whose head repository is this repository (`github.event.pull_request.head.repo.full_name == github.repository`), and for a `workflow_dispatch` on `refs/heads/main` (lines 279-281; `python-abi.yml` lines 182-184). The condition reads only the `github` context, because a job's `if` is evaluated before its matrix is expanded. A fork's pull request that leaves the condition unchanged, or one whose head repository was deleted, skips both jobs: an intentional not-run, never a pass. GitHub reports a skipped job as successful to a required status check, so if these checks are ever made required, a skipped Windows check is not a result. The Linux, macOS, and UBI jobs run as before, on fork pull requests too. +- Manual runs: GitHub lets only a user with write access to the repository start a `workflow_dispatch` run, and a run on `refs/heads/main` uses the workflow file on `main`. Starting such a run is all that an approved manual run on `main` means here: no environment protection rule, required reviewer, or other second approval gates it. A repository setting alone cannot add one: an environment's protection rules apply only to a job that names the environment, and these jobs name none, so adding one takes both an environment in the repository settings and an `environment` key on these jobs, which is a workflow change. The owner decides it. A user with write access can also change the condition on another branch and start a run there, so the condition does not limit users with write access. +- Risks: pull request code runs with administrator rights on a virtual machine that GitHub discards after the job. For the job's length it can change the machine's system state, install software, and read anything on it. It gets the read-only token and no secret or OIDC token, as above. The job condition is not a control against a malicious fork: it lives in the workflow file, and a `pull_request` run uses the workflow file of the pull request's merge commit, so a fork's pull request that edits the condition or these jobs runs them as edited. Whether such a run starts without a maintainer's approval depends on the repository's GitHub Actions setting for workflow runs from fork pull requests, not on any file in this repository. GitHub documents three levels (approval for first-time contributors who are new to GitHub, for first-time contributors, or for all external contributors) and states that by default all first-time contributors require approval, so under the default a contributor who has had a commit or pull request merged can run an edited workflow from a fork without approval. The level this repository uses was not checked, and changing it is the owner's decision. Beyond that setting, the read-only, secret-free token is the limit, as for every other job. A pull request from a branch of this repository is trusted to the extent of its author's write access. The privilege policy is not evaluated for these records, so they say nothing about a run without administrator rights. +- Approval basis: the repository owner's decision of 2026-10-12, quoted in the description of pull request #19, approving the GitHub-hosted Windows administrator token as a temporary exception limited to PR C's functional and ABI checks, with Windows runs limited to this repository's pull requests and manual runs on `main` as described above. The owner confirmed the review date below on the same day. The decision answers a Codex automated review finding on pull request #19 that asked for the Windows lanes to run under a non-administrator token: that finding is accepted as this time-limited exception, not fixed in code. +- End conditions: the exception ends when the records of these jobs pass the default policy without `--no-policy`; then `--no-policy` and `x-policy-exception` are removed from these jobs. That needs at least two things this change does not provide. One is a Windows run without an administrator token, so that a record can pass `app-non-root`; GitHub-hosted Windows runners offering such a run would also provide it. The other is a collector that reads Windows token privileges, so that a record can pass `minimal-capabilities`: today the collector records Windows capabilities as `unknown`, which fails that rule even without an administrator token ([CI-MATRIX.md](CI-MATRIX.md) "Policy"). Any other use, in another job, workflow, or lane, needs a new approval. Until it ends, the owner reviews it by 2026-11-08, the date the owner confirmed on 2026-10-12 and the recheck date of the dependency exceptions in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#compatibility-and-security-exceptions), and renews or ends it. Nothing in the workflows reads that date: the jobs keep running with `--no-policy` until a change removes it, which is also how a decision to end the exception takes effect. +- Follow-up, not in this change: a Windows execution environment that runs these checks without an administrator token, and a collector probe for Windows token privileges ([CI-MATRIX.md](CI-MATRIX.md) "Adding a lane" lists it among the probes a lane may need). This change adds no runner, image, probe, or other infrastructure for them and uses no self-hosted runner. + ## Requirements Each area opens with its invariant, written as target behavior. The invariant is Planned unless an item below it is marked Implemented or Partial. diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md index fba4bce..f46c3ec 100644 --- a/docs/SECURITY-REVIEW.md +++ b/docs/SECURITY-REVIEW.md @@ -6,7 +6,7 @@ It is not a security policy and defines no requirement: the meaning and status o ## Status -- In effect for every change that matches a trigger below or a run condition under "When this review runs", from HEAD 8ec5623 (committed 2026-09-21; the repository facts in this document were verified on 2026-10-08). The procedure itself depends on nothing that is Planned. (Updated 2026-10-10: the step 1 file table was re-checked at 14b9109 (unchanged through 3fe4d1a) and now lists `frontend/src/proxy.ts`, the lockfiles, and the runtime version files. Later the same day, CI workflows were added to the triggers and to the step 1 file table. On 2026-10-11, `scripts/ci/` and `tests/compatibility/` were added to the CI trigger and to that table row, and step 1 gained the check of compatibility policy exceptions.) +- In effect for every change that matches a trigger below or a run condition under "When this review runs", from HEAD 8ec5623 (committed 2026-09-21; the repository facts in this document were verified on 2026-10-08). The procedure itself depends on nothing that is Planned. (Updated 2026-10-10: the step 1 file table was re-checked at 14b9109 (unchanged through 3fe4d1a) and now lists `frontend/src/proxy.ts`, the lockfiles, and the runtime version files. Later the same day, CI workflows were added to the triggers and to the step 1 file table. On 2026-10-11, `scripts/ci/` and `tests/compatibility/` were added to the CI trigger and to that table row, and step 1 gained the check of compatibility policy exceptions. On 2026-10-12, the step 1 CI row names the compatibility and Python ABI workflows' facts, and step 1 gained what to record for container images and container options.) - Implemented today, and therefore reviewable as behavior: the health endpoints, the ASGI composition, `DATABASE_URL` parsing with SQLite fallback, and the S3-compatible storage backend. Their security facts, with file and line evidence, are in [SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md) "Current implementation security facts". - Not implemented: authentication, authorization, scope checks, sharing, approval, audit, RAG, model calls, Tasks, runtimes beyond a lazy Playwright factory, and WebMCP. Most `SEC-*` requirements are Planned; "How Planned requirements are reviewed" below says what that means for a review. - WebMCP is Experimental: an external draft with no default-on browser support as of 2026-10-08 ([WEBMCP.md](WEBMCP.md)). In the AGENT area, `SEC-AGENT-001` is Planned and `SEC-AGENT-002` to `SEC-AGENT-005` are Planned/Experimental. @@ -63,7 +63,7 @@ Map every file in the change to the boundaries in [SECURITY-ARCHITECTURE.md](SEC | `backend/agent/runtime/`, `backend/agent/llm/`, `backend/agent/rag/`, `backend/agent/orchestration/`, `backend/agent/tools/` | Runtimes, and model to content (docstring-only packages or a lazy factory today) | | `frontend/src/lib/api.ts`, `frontend/next.config.ts`, `frontend/src/proxy.ts`, `frontend/src/components/`, `frontend/src/app/` | Browser to control plane (the client side of it; `next.config.ts` and `src/proxy.ts` run in the Next.js server, which forwards `/core/*` and `/agent/*` to the backend and redirects UI paths, and authorizes nothing) | | `.env.example`, `.gitignore`, `backend/requirements.txt`, `backend/locks/*`, `frontend/package.json`, `frontend/package-lock.json`, `.nvmrc`, `.python-version` | No boundary: the "Secrets hygiene" implementation facts, plus the dependency surface | -| `.github/workflows/*`, `scripts/ci/*`, `tests/compatibility/*` | No product boundary: repository to CI runner, where pull request code runs on the runner and with the token the workflow gives it; the "CI workflow" implementation facts. The compatibility collector reads runner state and allowlisted environment variables and its record is published as a run artifact | +| `.github/workflows/*`, `scripts/ci/*`, `tests/compatibility/*` | No product boundary: repository to CI runner, where pull request code runs on the runner and with the token the workflow gives it; the "CI workflow" and "Compatibility and Python ABI workflows" implementation facts. The compatibility collector reads runner state and allowlisted environment variables and its record is published as a run artifact; the wheel probe reads PyPI's JSON API and installs hash-locked wheels; the UBI lanes run a pulled image in rootless Podman | Record: @@ -71,7 +71,7 @@ Record: - The trust level on each side, and on which side the new code runs. - The triggers matched and, from the table above, the areas and IDs to examine, plus any ID the change itself cites. -For a change under `.github/workflows/`, also record the trigger events (`pull_request_target` and `workflow_run` run with the base repository's token and secrets), the `permissions` each job gets, every secret referenced, every `runs-on` label (a self-hosted runner runs pull request code on that machine), and whether each action is pinned to a full commit SHA. +For a change under `.github/workflows/`, also record the trigger events (`pull_request_target` and `workflow_run` run with the base repository's token and secrets), the `permissions` each job gets, every secret referenced, every `runs-on` label (a self-hosted runner runs pull request code on that machine), and whether each action is pinned to a full commit SHA. For a container a workflow pulls or starts, also record whether the image is pinned by digest and has a freshness and provenance review in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#security-floors-and-release-freshness), whether the engine is checked to run rootless with no fallback to a rootful engine, Docker, or `sudo`, and every `--privileged`, `--device`, `--cap-add`, host namespace, and mount option, and which mounts are writable. For a change to a profile under `tests/compatibility/profiles/` that adds or changes a policy exception, check that its approval reference points to a decision by someone with authority over the repository, such as a maintainer's review or comment, and not to the change itself; that its scope names only the layers, stages and escalation methods, capabilities, or devices the lane needs; and that its end condition can be checked. The validator checks only that an approval is recorded, and its `policy-exception-applied` warning in the job log shows each use. A profile whose support status or lane changes without the matching edit to the lane table in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes) is a finding. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2efc751..a20757b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,7 +4,7 @@ This document separates checks for the current runnable scaffold from future fea ## Tests that exist today -The repository contains these automated tests. Their existence is a fact; whether they pass must be shown by running them and citing the output. The backend has 23 test methods, run by both `python manage.py test core agent` and pytest (with pytest-django), which collect the same 23 test IDs. The frontend has 12 Playwright tests (one `home.spec.ts` scenario and two `proxy.spec.ts` tests, each run in four projects) and 13 Vitest unit tests in two files. The CI script `scripts/ci/check_test_lock.py` has 49 `unittest` regression tests, and the compatibility tooling of [CI-MATRIX.md](CI-MATRIX.md) has 167 (95 for the validator and 72 for the environment collector). +The repository contains these automated tests. Their existence is a fact; whether they pass must be shown by running them and citing the output. The backend has 23 test methods, run by both `python manage.py test core agent` and pytest (with pytest-django), which collect the same 23 test IDs. The frontend has 12 Playwright tests (one `home.spec.ts` scenario and two `proxy.spec.ts` tests, each run in four projects) and 13 Vitest unit tests in two files. The CI script `scripts/ci/check_test_lock.py` has 49 `unittest` regression tests, and the compatibility tooling of [CI-MATRIX.md](CI-MATRIX.md) has 203 (95 for the validator, 84 for the environment collector, and 24 for the wheel and Python ABI probe). | Location | What it covers | |---|---| @@ -18,7 +18,8 @@ The repository contains these automated tests. Their existence is a fact; whethe | `frontend/tests/unit/HealthCard.test.tsx` | Vitest with React Testing Library in jsdom, 8 tests; `@/lib/api` and `sweetalert2` are mocked, so no request leaves the test. The card checks both endpoints on mount and shows each response; a failed service shows Disconnected without an alert on the initial check; a failed retry raises one SweetAlert2 alert naming each failed service (plural and singular wording), and a successful retry raises none; Retry is ignored while a check is in flight; a retry that fails after unmount raises no alert; and under React `StrictMode` a superseded check that settles later cannot overwrite the newest result. | | `scripts/ci/tests/test_check_test_lock.py` | `unittest`, 49 tests of the lock check described under "Current scaffold checks". 42 run the check with `python -I` in a subprocess on copies of the two backend locks and their requirement inputs, most of them edited, and expect a given failure message or a pass: missing, mismatched, duplicate, or unhashed pins; unexpected lock lines; a header for another target, without its command, or in uv's `--flag=value` form; test-only packages from the wrong origin; requirements that are unpinned, excluded by their specifier, dropped, or added without regenerating; unsupported input lines, including archive file names, backslash escapes, characters outside printable ASCII, and numbers too long for uv; a file that starts with a byte order mark; direct URLs; dropped, added, swapped, and unknown extras; markers that hold or are false on the target, including on a package the locks do not pin; unsupported marker forms; a `# via` annotation naming a file that is not an input of that lock; a `uv.toml` or a `pyproject.toml` with or without uv settings; and an environment that lacks a pin or holds another version. Seven call its functions in-process on made-up inputs: the metadata walk (markers evaluated for the target even when the running machine reports Windows, extras requested through other extras or after their package was first reached, and dependency problems), input markers evaluated for the target when the running machine reports Windows, the supported and unsupported marker forms, and input files that are not UTF-8 or nest a marker too deeply. | | `scripts/ci/tests/test_compatibility_schema.py` | `unittest`, 95 tests of `scripts/ci/validate_compatibility.py`. The committed profile and the nine synthetic fixtures validate, the fixtures are marked synthetic, and together they cover the scenarios [CI-MATRIX.md](CI-MATRIX.md) lists. The schema interpreter: every keyword it applies is exercised by a case that must fail, a schema with an unsupported keyword such as `oneOf`, `anyOf`, `allOf`, `not`, or `format` is rejected, and strict loading rejects duplicate keys, `NaN`, `Infinity`, overflowing numbers, a byte order mark, invalid UTF-8, deep nesting, and oversized files, read only up to the size limit. A schema is also rejected for another dialect, `$schema`, `$id`, or `$defs` below the root, a non-string annotation or type name, a pattern that Python and ECMA-262 read differently (escaped letters such as `\d`, `\A`, `\a`, `\N`, and `\U`, `\u` escapes of surrogates, `(?` groups, `{,n}`, possessive quantifiers, and a class that opens with `]`), or a `$ref` cycle. Each rule across fields, on edited copies of the fixtures: provenance and synthetic marking (including `--require-real`), control characters (named by a path that writes a key over 128 characters once escaped as its length and the first 8 hexadecimal digits of its SHA-256, so keys of one length are told apart unless those digits agree, keeps at most 16 keys around a `~...` that no key can be mistaken for, and is built only when the walk reaches its value), lone surrogates and secret-shaped values (a sample of every shape, and of both Slack prefixes, on its own and right after an ASCII or other letter, an AWS key ID followed by an underscore or a lowercase letter and a run of 17 key characters that is not one, the shortest JSON Web Token and one a character too short, and a long run of token characters searched in linear time), a collection time that is not a valid date and time, layer nesting (including a documented technology on a layer of another kind, a WSL2 layer with another technology, a host that names one, and a guest or container whose technology is `not-applicable`), OS, CPU, and ABI contradictions (with emulated and 32-bit cases that must pass, emulation inherited by every inner layer and runtime, the ABI tag's OS and architecture also when the version is unknown, an ABI tag architecture outside the list against a listed arch fact but not an unknown or unlisted one, a CPython version read whole (pre-release and `+` forms that must pass as CPython 3.12, though only a release of 3.12.13 or newer meets the checked lane's bound, text after the version and leading zeros, which also lose the lane, and another implementation's version left unchecked), a CPython platform tag and ABI tag compared with each other when the innermost layer's OS family is unknown, or the arch fact is unknown or not a listed name (by architecture only when both name one OS), and, when the layer is known, a platform tag for another OS reported against the layer once and not compared with the ABI tag, the ABI tag's C library against the libc fact (also when one of them names another C library, and not between two other names), Windows and macOS platform tags, and a glibc or musl libc fact on Windows or macOS, named by the innermost layer, the platform tag or the SOABI alone, or on FreeBSD, named by the layer or the platform tag alone), unrecognized values as warnings only, identity (also on macOS and FreeBSD), stage fields and order, which stages may be not-applicable, the log or artifact a real stage cites, `detect` with probe errors, failed versus not-run, `overall`, required stages, CPU fallback reported as a GPU pass, a passed stage whose compute record names no backend that ran, whatever was requested (reported once, as a fallback, on a GPU request, and not for a failed stage), an accelerator profile's stages, `performance` included, requested and run on its backend, backend references, each default policy rule (including capabilities on Linux or an unknown OS family, and a workload not shown to run outside a container, which a WSL2 distribution's own PID namespace does not trigger), rootless engine and non-root user as independent rules, policy decided by measured facts only (declared values do not show compliance, an unknown privilege state fails, off Linux an unmeasured layer kind fails, and a real or saved user ID of 0 fails), exceptions that are pending, out of scope, unused, missing fields, or approved on a day that does not exist, scoped by escalation method, or offered for a state that is not measured, the warnings for an applied exception and a skipped policy, a profile that weakens a rule, support claims against the lane table and bound to the lane's platform and the lock's interpreter (the C library from the libc fact before the ABI tag, `musl` included, `glibc` or `musl` read in any ASCII letter case at the start of the fact only and with no letter after it, with the error at that fact, and a libc fact or ABI tag that names another C library contradicting the lane whatever the status), version bounds (macOS Apple Silicon by macOS version, statically resolvable for 15 or newer and not supported for 14 or older; Linux x86-64 glibc checked for glibc 2.39 or newer, read from the libc fact in any ASCII letter case and not from the ABI tag, and CPython 3.12.13 or newer as a release; a version part with a leading zero or more than four digits, or more than one blank after `glibc`, is not read; a claim whose versions miss a bound or are not declared fails, and a real profile copies the status its versions meet or else records not-assessed, and on a lane without bounds copies the lane's status), the validator's lanes and version bounds kept equal to the table's, each bound naming a version the validator reads, a lane table that lists a lane twice (a usage error), evidence sources (with a comment line inside `jobs:`, job keys indented by four spaces, quoted, or followed by a tab or a comment, a quoted `jobs` key or one with a blank before its colon, and U+0085 inside a value, which name the job, and a deeper key, a longer name, no space after the colon, a comment right after a colon, an indented `jobs` key, a `jobs` block scalar, a `jobs` mapping in flow style, U+2028 inside a value, or a dot in the job name matched as another character, which name none, and a workflow file that is not UTF-8 or a path the system refuses, each reported as a finding that names why), the profile link (job, workflow, a workflow ref in another repository, including one whose name only starts with the record's, or with no workflow file or no ref, a ref holding `@`, which links, a record without its workflow ref or its repository, which links to no profile and is named by the field it lacks, and a real profile's GitHub Actions record) and the comparison of every declared fact (with the layer count, runtimes, backend API, runner, and `not-applicable` facts each checked on their own, and declared values in the result not counting), a blocked stage, fallback from an NPU and to another GPU, and CI evidence (each run and commit field, the workflow ref included, also when declared rather than observed or inferred, a run attempt of 0, and a run URL for another attempt or with an attempt longer than any `run_attempt`, while one with no attempt names the run). Command-line exit codes 0, 1, and 2, an unwritable or unusable schema, a schema given with `--schema` that accepts a document or profile the rules across fields cannot read (exit 2, not a traceback), and an exception in a rule under the default schema, however its path is written, which is raised, not reported as a usage error, an unwritable summary or one whose path loops, a summary that names the schema, the profile, the lane table, a document (the document also through a hard link or another spelling of its path), or a workflow file a real profile of the run names as its evidence source (also through a hard link, also when that file is missing, which it then does not create while it writes a summary elsewhere, whether that profile is given with `--profile` or as a document, and also when `synthetic` is a false value other than `false`), which leaves every input unchanged, a summary whose cells escape Markdown and that lists at most 200 findings, file names (including one that starts with `::`) and document text that cannot forge an output line, including the runner's `##[` command form anywhere in a line, a command-line error that repeats such an argument, a secret-shaped one, or such a program name, and a secret-shaped value or file name that the printed lines and the summary do not show, also after an escape the finding writes just before the value (`\n` from `repr`, `~1` in a JSON Pointer). | -| `scripts/ci/tests/test_environment_probe.py` | `unittest`, 72 tests of `scripts/ci/collect_environment.py` on fake machines built in temporary directories, with the interpreter facts fixed to match each machine: a GitHub-hosted runner VM, a Hyper-V VM named only by its VMBus, a machine with no virtualization signal (not reported as bare metal), Docker in a VM, a container that leaves none of the markers (told apart from the VM by its PID namespace), an Apptainer container in the initial PID namespace, signals that do and do not show a VM (EC2 metal instances and an EC2 Arm VM, WSL 1 with the hypervisor flag), rootless Podman with a non-root user and with root, WSL2 with current and older kernels and a distribution in its own PID namespace, an emulated process (recorded on the runtime, not the container layer), an old kernel without `/proc/sys/kernel/arch`, the interpreter's build architecture (from the ELF header only when its byte order and class match the name, so a big-endian ppc64, aarch64 or armeb is not read as ppc64le, aarch64 or armv7, nor a 32-bit x32, aarch64 ILP32, riscv32, loongarch32 or 31-bit s390 build as its 64-bit name) and free-threading flag, a platform tag overridden by `_PYTHON_HOST_PLATFORM` or malformed (recorded as `unknown`), the C library version read only through `os.confstr`, Node on Power named from Node's own byte order, not Python's, kernel and AppArmor text with control characters, an AppArmor `enabled` value other than `Y` or `N` and a newline (also padded, or longer than the 16 bytes read), the service manager, a Kubernetes pod, systemd in a container, container markers that disagree, Docker in a user namespace of its own, the kernel release read from `/proc` with `uname` only as an inferred fallback, os-release values that fail their checks, a FIFO in place of a file, and the provenance each derived fact keeps; the real and saved user IDs and a process that can regain root, cgroup version and delegation (not delegated without write access to the directory, `cgroup.procs`, `cgroup.threads` where it exists, or `cgroup.subtree_control`, with a `cgroup.threads` that cannot be looked up counted as present, and left unknown for real or saved root and for permitted capabilities, and for a cgroup path that leaves `/sys/fs/cgroup`), security modules, effective and permitted capabilities, missing files giving `unknown` values, and a failing probe kept apart from the other values; Windows, validated with the policy, which fails closed, the user IDs on FreeBSD and macOS and none under Cygwin, and the FreeBSD platform tag; pull request, `pull_request_target`, and push events, a run attempt up to the schema's largest integer, 4294967295 (a larger one is `unknown`), a manual dispatch from a branch with `#`, a comma, an apostrophe, `@`, a non-ASCII letter, or a trailing U+3000, checked out by its name as actions/checkout does, whose ref, workflow ref, and commit (from a loose ref or `packed-refs`) are recorded and which links to the profile, a workflow ref that is `unknown` for a branch with a space, `~`, `:`, `\`, a control or bidirectional formatting character, or a lone surrogate, or longer than 200 characters, a ref read in the same way, the characters a ref may hold checked against the validator's on every code point, malformed, unparsable, and missing event files and a missing event path, a checkout ref that leaves `.git`, that holds `..` or `@{`, a part that is empty, starts with `.`, or ends with `.lock` or `.`, a space, a control or bidirectional formatting character, or a byte that is not UTF-8 (not read as U+FFFD, the name of another branch), or that is longer than 200 characters, which is not followed even when `packed-refs` lists it, refs that hold `{`, `@`, or `.lock` elsewhere and are followed, `packed-refs` lines that end at `\n` only, a `packed-refs` line that is not UTF-8 (not read as U+FFFD, and not stopping the lookup), a ref that is not ASCII under a file system encoding other than UTF-8, which is not followed, and a `GITHUB_REF` or `GITHUB_WORKFLOW_REF` that is not ASCII under such an encoding, recorded as `unknown`, a loose ref that does not exist, looked up in `packed-refs`, and one that cannot be read or holds no commit ID (a symbolic ref), which is not, and the variable allowlist (which no longer holds the WSL variables); `node` run with an empty environment (on Windows, `SystemRoot` alone), looked for only on the `PATH` given (an empty `PATH` finds none, an unset one the default directories, and on Windows `node.exe` alone is looked for, not a `node.bat`, `node.cmd`, `node.com` or `node` earlier on `PATH`, without `shutil.which` and so in the same way on CPython 3.11 and 3.12, an empty entry names no directory, and the current directory is searched only when `PATH` names it, not when `PATH` is unset and the default directories list it first), failing, absent, skipped, reporting values that fail their checks, writing more than 4 KiB (and then ignoring the closed pipe), or not finishing in time (also after closing its output, by the deadline the read had), and a timer that fires after `node` was reaped, also after a failed read or right after the wait, which then sends no signal, or as the read ends, which holds back the reap until its signal is sent; stage option errors, including a value of every secret shape, which both the collector and the validator refuse; exit status 1 after a probe failure with the record still written; an unwritable output or summary; a summary that names the record's own file, also through a hard link or another spelling of its path, which leaves the record valid JSON; an output file name that would start a workflow command or is shaped like a token, which the printed line does not show, and a command-line error that repeats such an argument, a secret-shaped one, or such a program name, with the collector's line handling checked against the validator's; a record holding a secret-shaped value, which is not written, also when the JSON text writes the character before it as an escape such as `\n`, with the collector's secret shapes checked against the validator's; a summary with Markdown in a recorded value; and the tables both scripts keep (architecture names and which of them are 32-bit, platform tags, stages, the overall verdict, and the layer kind of every technology the collector writes), checked against each other and, for the platform tags, against expected values. One runs the probe's `node -p` expression with an empty environment on the `node` on `PATH`, when there is one, and checks that it gives every value the probe reads, the byte order included; a version-manager shim that needs `HOME` fails it. Two run the collector on the machine running the tests, with secret-shaped values in its environment, and check that no secret, host name, or home directory reaches the record and that the record is valid. Four read the collector's source and check its imports, that it never reads the whole environment or the host name, starts one subprocess without a shell, with an empty environment or, on Windows, `SystemRoot` alone, and discards its stderr, and opens files for reading except its outputs. | +| `scripts/ci/tests/test_environment_probe.py` | `unittest`, 84 tests of `scripts/ci/collect_environment.py` on fake machines built in temporary directories, with the interpreter facts fixed to match each machine: a GitHub-hosted runner VM, a Hyper-V VM named only by its VMBus, a machine with no virtualization signal (not reported as bare metal), Docker in a VM, a container that leaves none of the markers (told apart from the VM by its PID namespace), an Apptainer container in the initial PID namespace, signals that do and do not show a VM (EC2 metal instances and an EC2 Arm VM, WSL 1 with the hypervisor flag), rootless Podman with a non-root user and with root, WSL2 with current and older kernels and a distribution in its own PID namespace, an emulated process (recorded on the runtime, not the container layer), an old kernel without `/proc/sys/kernel/arch`, the interpreter's build architecture (from the ELF header only when its byte order and class match the name, so a big-endian ppc64, aarch64 or armeb is not read as ppc64le, aarch64 or armv7, nor a 32-bit x32, aarch64 ILP32, riscv32, loongarch32 or 31-bit s390 build as its 64-bit name) and free-threading flag, a platform tag overridden by `_PYTHON_HOST_PLATFORM` or malformed (recorded as `unknown`), the C library version read only through `os.confstr`, Node on Power named from Node's own byte order, not Python's, kernel and AppArmor text with control characters, an AppArmor `enabled` value other than `Y` or `N` and a newline (also padded, or longer than the 16 bytes read), the service manager, a Kubernetes pod, systemd in a container, container markers that disagree, Docker in a user namespace of its own, the kernel release read from `/proc` with `uname` only as an inferred fallback, os-release values that fail their checks, a FIFO in place of a file, and the provenance each derived fact keeps; Windows x64 and Windows on Arm, with the machine named by `IsWow64Process2` and an x64 process emulated on Windows on Arm (Python and Node each compared with the machine), an elevated, administrator, or LocalSystem token recorded as the superuser and a token that cannot be read in full left `unknown` (both failing `policy-app-non-root`), the capabilities left `unknown` because the token's privileges are not read (so a token without administrator rights still fails `policy-minimal-capabilities`), and BIOS values that name Hyper-V, QEMU, Amazon, or VMware, while values that name no hypervisor record no layer kind; macOS on Apple Silicon and on Intel read through `sysctlbyname`, a process Rosetta translates, a root process, and values that are missing or fail their checks; the real and saved user IDs and a process that can regain root, cgroup version and delegation (not delegated without write access to the directory, `cgroup.procs`, `cgroup.threads` where it exists, or `cgroup.subtree_control`, with a `cgroup.threads` that cannot be looked up counted as present, and left unknown for real or saved root and for permitted capabilities, and for a cgroup path that leaves `/sys/fs/cgroup`), security modules, effective and permitted capabilities, missing files giving `unknown` values, and a failing probe kept apart from the other values; the user IDs on FreeBSD and macOS and none under Cygwin, the FreeBSD platform tag, and no Linux capability rule on macOS; pull request, `pull_request_target`, and push events, a run attempt up to the schema's largest integer, 4294967295 (a larger one is `unknown`), a manual dispatch from a branch with `#`, a comma, an apostrophe, `@`, a non-ASCII letter, or a trailing U+3000, checked out by its name as actions/checkout does, whose ref, workflow ref, and commit (from a loose ref or `packed-refs`) are recorded and which links to the profile, a workflow ref that is `unknown` for a branch with a space, `~`, `:`, `\`, a control or bidirectional formatting character, or a lone surrogate, or longer than 200 characters, a ref read in the same way, the characters a ref may hold checked against the validator's on every code point, malformed, unparsable, and missing event files and a missing event path, a checkout ref that leaves `.git`, that holds `..` or `@{`, a part that is empty, starts with `.`, or ends with `.lock` or `.`, a space, a control or bidirectional formatting character, or a byte that is not UTF-8 (not read as U+FFFD, the name of another branch), or that is longer than 200 characters, which is not followed even when `packed-refs` lists it, refs that hold `{`, `@`, or `.lock` elsewhere and are followed, `packed-refs` lines that end at `\n` only, a `packed-refs` line that is not UTF-8 (not read as U+FFFD, and not stopping the lookup), a ref that is not ASCII under a file system encoding other than UTF-8, which is not followed, and a `GITHUB_REF` or `GITHUB_WORKFLOW_REF` that is not ASCII under such an encoding, recorded as `unknown`, a loose ref that does not exist, looked up in `packed-refs`, and one that cannot be read or holds no commit ID (a symbolic ref), which is not, and the variable allowlist (which no longer holds the WSL variables); `node` run with an empty environment (on Windows, `SystemRoot` alone), looked for only on the `PATH` given (an empty `PATH` finds none, an unset one the default directories, and on Windows `node.exe` alone is looked for, not a `node.bat`, `node.cmd`, `node.com` or `node` earlier on `PATH`, without `shutil.which` and so in the same way on CPython 3.11 and 3.12, an empty entry names no directory, and the current directory is searched only when `PATH` names it, not when `PATH` is unset and the default directories list it first), failing, absent, skipped, reporting values that fail their checks, writing more than 4 KiB (and then ignoring the closed pipe), or not finishing in time (also after closing its output, by the deadline the read had), and a timer that fires after `node` was reaped, also after a failed read or right after the wait, which then sends no signal, or as the read ends, which holds back the reap until its signal is sent; stage option errors, including a value of every secret shape, which both the collector and the validator refuse; exit status 1 after a probe failure with the record still written; `--extension` files kept under their `x-` name, and a name without the prefix, a file that is missing, not a JSON object, not UTF-8, larger than 1 MiB, holds a secret-shaped value, or is given twice, each refused with exit status 2 and no record written; `--amend-container`, which adds what `podman info` and `podman container inspect` report to the container layer of a record collected inside it (rootless, privileged, and devices, also when they are rootful or privileged), changes nothing when the output does not describe that running container, and starts its two subprocesses without a shell; an unwritable output or summary; a summary that names the record's own file, also through a hard link or another spelling of its path, which leaves the record valid JSON; an output file name that would start a workflow command or is shaped like a token, which the printed line does not show, and a command-line error that repeats such an argument, a secret-shaped one, or such a program name, with the collector's line handling checked against the validator's; a record holding a secret-shaped value, which is not written, also when the JSON text writes the character before it as an escape such as `\n`, with the collector's secret shapes checked against the validator's; a summary with Markdown in a recorded value; and the tables both scripts keep (architecture names and which of them are 32-bit, platform tags, stages, the overall verdict, and the layer kind of every technology the collector writes), checked against each other and, for the platform tags, against expected values. One runs the probe's `node -p` expression with an empty environment on the `node` on `PATH`, when there is one, and checks that it gives every value the probe reads, the byte order included; a version-manager shim that needs `HOME` fails it. Two run the collector on the machine running the tests, with secret-shaped values in its environment, and check that no secret, host name, or home directory reaches the record and that the record is valid. Four read the collector's source and check its imports, that it never reads the whole environment or the host name, starts `node` without a shell, with an empty environment or, on Windows, `SystemRoot` alone, and discards its stderr, starts `podman` only in the amend mode, without a shell or input, and opens files for reading except its outputs. | +| `scripts/ci/tests/test_python_abi_probe.py` | `unittest`, 35 tests of `scripts/ci/probe_python_abi.py`, which needs `packaging` (installed from the backend lock with its hash first). Most feed it made-up index metadata and tag lists, so they do not depend on the interpreter: reading the backend lock (its 50 pins, the `# via` graph followed transitively, requirement lines that keep every hash, and a pin given twice, without a hash, or in an unexpected form refused), the wheels of a release (a compressed tag set such as `abi3.abi3t` expanded to each tag, yanked files kept, and metadata for another release, with a malformed digest or file name, larger than its limit, or listing no files refused), the index asked for the locked release only, the order in which this interpreter accepts tags, wheels whose Requires-Python excludes this version dropped as installers drop them (an unreadable one excludes nothing, as in pip), the five ABI categories (`cp315-cp315`, `cp315-cp315t`, `cp315-abi3`, `cp315-abi3t`, and `cp315-abi3.abi3t`) and older `abi3` tags, the selected wheel found by its tags and the lock's hashes, the install status (a missing wheel is `no-wheel` or, when one was published after the lock, `not-in-lock`, both blocked, while an install that fails with a wheel in the lock is `error` and one that passes with none predicted is `contradiction`, both failed, and a package whose lock dependency is blocked or failed takes that outcome), the overall outcome (a package that enables the GIL again, or whose probe process did not exit with status 0, fails `execute` even when its import and call passed), the backend jobs' install stage (blocked only when a uv line that says a package has no wheel names one the wheel report shows uncovered, or when every problem `uv pip check` prints, as many as the count it prints, is a requirement that is not installed and that the lock does not pin, such as `tzdata` on Windows with the lines uv 0.12.24 printed there, and failed otherwise, including a network error, a failed download line, a missing report, an installed version a requirement excludes, a version conflict beside a missing `tzdata`, a count that does not match the lines, and a check failure nothing explains), unmet requirements read from fake installed distributions (missing, or installed at a version the requirement excludes, with an unrequested extra ignored), `probe_package` and the `packages` command with uv, the installed distributions, and the import probe replaced (a package installed with its lock dependency and probed, a dependency that fails or is blocked, a package with no wheel, which is installed once and not imported, and the report, GitHub outputs, and exit status, including exit status 1 when a package enables the GIL again or its probe process is ended by a signal, shown in the summary's Exit column), recorded text without this environment's paths or control characters, output lines that cannot be forged, and the import and call run in a fresh process with the GIL state before and after, with an import failure, a call failure, and a warning kept apart, and the process's exit checked after its result: status 0 passes; with `subprocess.run` replaced, a printed passing result followed by status 3, by -11 (`SIGSEGV`), by an unknown signal number, or by the Windows crash status 0xC0000005 fails, on every OS; in a real process, an `atexit` hook that flushes the result and then calls `os._exit(3)` fails, as does one that sends itself `SIGKILL` (POSIX only, skipped on Windows), and a process that exits with status 4 before printing a result fails as an import with no result. Four check this interpreter's own facts: SOABI, extension suffixes, and the GIL state agree, the tags match the build, and, only on CPython 3.15 with the GIL or on 3.15t, which tags and suffixes it accepts (3.15t takes `cp315t` and `abi3t` wheels and no `abi3` one) and that `sys._is_gil_enabled()` and `Py_GIL_DISABLED` match; on any other interpreter those two are skipped. Three read the probe's source: two `subprocess.run` calls without a shell or input and with a timeout, uv run with `--no-config`, `--require-hashes`, `--only-binary :all:`, and `--no-deps`, no network host but the PyPI JSON API (and `https://pypi.org/simple` as uv's index), and no environment variable read or changed. No test establishes thread safety. | No test covers authentication, authorization, scope separation, approval, Tasks, retrieval, Browser sessions, realtime, i18n, or WebMCP, because none of those features exist. @@ -80,6 +81,20 @@ python -I scripts/ci/tests/test_compatibility_schema.py --verbose python -I scripts/ci/tests/test_environment_probe.py --verbose ``` +The wheel and Python ABI probe, `scripts/ci/probe_python_abi.py`, also uses the standard library, plus `packaging`, which it reads wheel tags with and which is pinned and hashed in the backend lock; it asks the PyPI JSON API which wheels each locked release publishes, so it needs network access. Its tests and a probe of the running interpreter, in an environment of CPython 3.12.15, 3.15.0, or 3.15t created with `uv venv --managed-python` and nothing else installed: + +```bash +# From the repository root, with that environment activated +python -I scripts/ci/probe_python_abi.py pin --lock backend/locks/cp312-linux-x86_64.txt --name packaging --output "${TMPDIR:-/tmp}/packaging.txt" +uv pip install --python python --require-hashes --only-binary :all: --no-deps -r "${TMPDIR:-/tmp}/packaging.txt" +python -I scripts/ci/tests/test_python_abi_probe.py --verbose +python -I scripts/ci/probe_python_abi.py wheels --lock backend/locks/cp312-linux-x86_64.txt --output "${TMPDIR:-/tmp}/wheels.json" +# CPython 3.15 or 3.15t only: installs each native package of the lock into this environment, imports it, and calls it +python -I scripts/ci/probe_python_abi.py packages --lock backend/locks/cp312-linux-x86_64.txt --uv "$(command -v uv)" --output "${TMPDIR:-/tmp}/python-abi.json" +``` + +`wheels` exits 0 once it wrote its report, whatever the report shows. `packages` exits 0 when every locked native package installed, imported, and ran, or was blocked because no wheel this interpreter accepts is in the lock, and 1 when an install failed with a wheel in the lock or an import or call failed. Both exit 2 on a usage error or an index response they cannot read. `packages` changes the environment it runs in, so run it only in a throwaway one. + The collector exits 0 when it wrote the record and detection passed, 1 when a probe failed (the record is still written), and 2 on a usage error, an unwritable record or summary, a record that would hold a secret-shaped value, which it then does not write, or a `--summary` that names the record's own file, which it then leaves as written; the validator exits 0 when every document is valid, with or without warnings, 1 when any is invalid, and 2 on a usage error or an unusable file, schema, lane table, or summary, including a lane table that lists a lane twice and a `--summary` that names its schema, profile, lane table, a document, or a workflow file a real profile of the run names as its evidence source, also one that is missing. The profile describes a GitHub-hosted `ubuntu-24.04` runner, so a record collected elsewhere, for example outside GitHub Actions, in a container, on another distribution, or as root, fails against it with `profile-link`, `profile-mismatch`, `profile-unverified`, or a `policy-*` code; that is the expected result, not a defect. To check only that such a record is well formed, validate it without `--profile` and with `--no-policy`. A synthetic fixture is validated with its own profile, for example `--profile tests/compatibility/fixtures/windows-arm64-wsl2-podman.profile.json tests/compatibility/fixtures/windows-arm64-wsl2-podman.result.json`. Each test file exits 5 when it runs no test, and is run directly with `python -I`, as for the lock check's tests. In Windows PowerShell the environment is activated with `.venv\Scripts\Activate.ps1` instead of `source .venv/bin/activate`, and the other commands are unchanged. Native Windows cannot produce a working environment from this Linux-resolved lock until a Windows lane exists: on Windows x64 the sync installs without `tzdata`, which Django and psycopg require on Windows, so `uv pip check` and time-zone handling fail; on Windows on Arm the sync itself fails because the pinned `autobahn`, `cryptography`, and `psycopg-binary` releases have no `win_arm64` wheels. Use WSL2 (Ubuntu) instead. @@ -131,7 +146,7 @@ The default browser is the binary paired with `@playwright/test`. If its downloa ## Continuous integration -Added 2026-10-10. `.github/workflows/ci.yml` runs the backend and frontend commands of "Current scaffold checks", plus `npx next typegen` and `npx tsc --noEmit`, on every pull request to `main`, every push to `main`, and on manual dispatch. It covers the checked lane only: GitHub-hosted `ubuntu-24.04` x86-64 runners, uv 0.12.24 with CPython 3.12.15 and the hash lock, and Node 24.21.0 (from `.nvmrc`) with npm 11.21.0. The first version of the workflow changed no test, lock, or package manifest; the pytest and Vitest jobs, added later on 2026-10-10, came with the backend test lock and the Vitest packages, and left the four original jobs unchanged. The compatibility job, added 2026-10-11, installs no package and left the six earlier jobs unchanged. A workflow file is not evidence that anything ran or passed: cite the run, its commit SHA, the job, and the summary line from its log, as for any other check. A `pull_request` run tests the merge of the pull request's head into its base as they were when the run started (`refs/pull//merge`), not the head commit alone: record both the head SHA and the merge commit SHA that the checkout step logs, and treat the run as stale once the head or the base moves. +Added 2026-10-10. `.github/workflows/ci.yml` runs the backend and frontend commands of "Current scaffold checks", plus `npx next typegen` and `npx tsc --noEmit`, on every pull request to `main`, every push to `main`, and on manual dispatch. It covers the checked lane only: GitHub-hosted `ubuntu-24.04` x86-64 runners, uv 0.12.24 with CPython 3.12.15 and the hash lock, and Node 24.21.0 (from `.nvmrc`) with npm 11.21.0. The first version of the workflow changed no test, lock, or package manifest; the pytest and Vitest jobs, added later on 2026-10-10, came with the backend test lock and the Vitest packages, and left the four original jobs unchanged. The compatibility job, added 2026-10-11, installs no package and left the six earlier jobs unchanged. The two workflows of "Compatibility and Python ABI workflows" below, added 2026-10-12, left `ci.yml` and its seven jobs unchanged. A workflow file is not evidence that anything ran or passed: cite the run, its commit SHA, the job, and the summary line from its log, as for any other check. A `pull_request` run tests the merge of the pull request's head into its base as they were when the run started (`refs/pull//merge`), not the head commit alone: record both the head SHA and the merge commit SHA that the checkout step logs, and treat the run as stale once the head or the base moves. | Job (check name) | What it runs | |---|---| @@ -147,9 +162,23 @@ Added 2026-10-10. `.github/workflows/ci.yml` runs the backend and frontend comma - The compatibility job uploads its record, `linux-x64-gha.result.json`, as the artifact `compatibility-linux-x64-attempt-`, kept for 90 days, whether or not validation passed, unless the run was cancelled. The collector does not write a record that holds a secret-shaped value. The record holds the runner's OS, kernel, CPU, runtime, and isolation facts, the run's commit IDs, and no environment variable outside the collector's allowlist. - No job depends on another, so a failure in one never turns another into a skipped check. When the four original jobs first passed on `main`, their check names became eligible as required status checks; the pytest, Vitest, and compatibility checks become eligible once they have passed. No change in this repository makes a check required. Making a check required is a GitHub repository setting, not a file in this repository, and changing it needs admin access to the repository. - A newer run for the same pull request cancels the older one. Every push to `main` and every manual dispatch runs in its own concurrency group, so none of those runs is cancelled or dropped from the queue. -- Not covered by CI: other operating systems, CPU architectures, and accelerators, of which only synthetic fixtures exist; the stages after `detect` on any lane; Python 3.15 and 3.15t; the manual route checks against `runserver` and Uvicorn above, for which no API collection exists (see "API collection testing" below); the Python Playwright browser install; accessibility and pixel-diff checks; secret scanning; and dependency alerts. +- Not covered by `ci.yml`: other operating systems and CPU architectures, the stages after `detect` on any lane, and Python 3.15 and 3.15t, which only the two workflows below run, on the pull requests that touch their paths and on manual dispatch. Not covered by any workflow: accelerators, of which only synthetic fixtures exist; a Windows run without an administrator token, a follow-up recorded under the exception in [SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token); the Windows jobs on a fork's pull request, which they skip; a RHEL host, SELinux in enforcing mode, WSL2, and a Linux distribution other than Ubuntu 24.04 and the two UBI images; the `security` and `performance` stages on any lane; thread safety under CPython 3.15t; the manual route checks against `runserver` and Uvicorn above, for which no API collection exists (see "API collection testing" below); the Python Playwright browser install; accessibility and pixel-diff checks; secret scanning; and dependency alerts. - A green run shows that these commands passed on that commit and runner. It is not a review and not an approval ([CODE-REVIEW.md](CODE-REVIEW.md) "Independence and authority"). A change to any workflow file goes through the security review ([CODE-REVIEW.md](CODE-REVIEW.md) step 9). +### Compatibility and Python ABI workflows + +Added 2026-10-12. `.github/workflows/compatibility.yml` and `.github/workflows/python-abi.yml` run on a pull request to `main` that changes one of the paths each lists (the workflow file itself, `scripts/ci/**`, `tests/compatibility/**`, `docs/CI-MATRIX.md`, and `docs/DEPENDENCY-STRATEGY.md` in both; `backend/**`, `frontend/**`, and `.nvmrc` in `compatibility.yml`; `backend/locks/**` in `python-abi.yml`) and on manual dispatch, not on a push to `main`. Like `ci.yml`, each has `contents: read` permissions, checks out without persisting credentials, pins every action to a commit SHA, uses no secret, and cancels an older run for the same pull request. Each job has a timeout, runs at most four matrix jobs at a time with `fail-fast: false`, and uploads `$RUNNER_TEMP/compatibility/` or `$RUNNER_TEMP/python-abi/` as an artifact kept for 90 days unless the run was cancelled. No job is a required check, and none uses `continue-on-error`. Each validates its record against its committed profile under `tests/compatibility/profiles/` with `--require-real --dependency-strategy docs/DEPENDENCY-STRATEGY.md`. The Windows runners run in their own jobs, `platform-windows` and `abi-windows`, with the same steps through YAML aliases; only those jobs, and only on Windows, add `--no-policy`, because the runner's user holds an elevated administrator token, which `policy-app-non-root` rejects. That is a temporary exception ([SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md#temporary-exception-windows-administrator-token)): their records hold `x-policy-exception` and a `security` reason saying the policy was skipped and security not verified, and the step summary and a warning annotation say so ([CI-MATRIX.md](CI-MATRIX.md) "Policy"). Those two jobs run only for a pull request from a branch of this repository and for a manual run on `main`. A fork's pull request skips them: that is an intentional not-run, not a pass, and since GitHub reports a skipped job as successful to a required check, a skipped Windows check shows nothing. The Linux, macOS, and UBI jobs run on every pull request that matches the paths, forks included. How the install stage is reported, and what each pass shows, are in [CI-MATRIX.md](CI-MATRIX.md) "Install stage in the lane jobs" and "What a passing job means". + +| Job (check name) | What it runs | +|---|---| +| `Platform / ` (`compatibility.yml`) | Seven lanes in two jobs with the same steps: `platform` runs `linux-x64` (`ubuntu-24.04`), `linux-arm64` (`ubuntu-24.04-arm`), `macos-arm64` (`macos-15`), and `macos-x64` (`macos-15-intel`); `platform-windows` runs `windows-x64` (`windows-2025`), `windows-arm64` and `windows-arm64-x64` (`windows-11-arm`, the second with x64 Python and Node, which Windows emulates), when the condition above allows it. uv 0.12.24 creates a CPython 3.12.15 environment for the runner's platform with `--managed-python`, and the version is checked. `probe_python_abi.py wheels` reports which locked packages have no wheel this interpreter accepts. The step that installs the backend lock with `uv pip sync --require-hashes --only-binary :all:` and runs `uv pip check` sets the install stage through `probe_python_abi.py install-stage`: passed, blocked when the reports explain the failure, or failed. Only after a passed install: `manage.py check` and `makemigrations --check --dry-run`, `manage.py test core agent` with a nonzero test count, and the test lock's install, `check_test_parity.py`, and `pytest`, each only after the one before passed. Except on `windows-arm64-x64`, the frontend checks of `ci.yml`, `npm run test:unit` included, run whatever the backend did. The collector then records the runner with every stage, the wheel and requirement reports as `x-wheels` and `x-requirements`, and the frontend steps' outcomes as `x-frontend` (`passed` or `failed` with the step's log, or `not-run` with a reason; the frontend is no stage of the record), and the Linux lanes run the three compatibility test files. | +| `UBI / / rootless Podman` (`compatibility.yml`) | Four lanes: UBI 9.8 and UBI 10.2, each on `ubuntu-24.04` and `ubuntu-24.04-arm`, pulled by index digest, with the image's architecture checked. The job fails, after recording the lane as blocked, when Podman is missing or does not run rootless for the runner's user; it never uses Docker or a rootful engine. The container runs as the runner's user (`--userns=keep-id`), with every capability dropped, `no-new-privileges`, no device, and not privileged; it mounts the checkout, the record directory, the host's uv binary (read-only), and the event file (read-only), and gets the collector's allowlisted variables. The image's system Python is recorded and not used (UBI 9 ships CPython 3.9 with SQLite 3.34.1, older than the 3.37.0 Django 6.1 needs). In the container, uv's CPython 3.12.15 runs the same backend stages as the platform job, without the frontend; the collector records the container from inside it, then, on the host, `--amend-container` adds what `podman info` and `podman container inspect` report, and the validator applies the default policy. | +| `Python ABI / / ` (`python-abi.yml`) | Twelve jobs: CPython 3.15.0 (`cp315`) and its free-threaded build (`cp315t`) on the six runner labels of the platform jobs, the four Linux and macOS ones in `abi` and the two Windows ones in `abi-windows`, which runs under the same condition as `platform-windows`. uv 0.13.0, the first release that offers CPython 3.15.0 rather than a release candidate, creates the environment; the job checks the version and that `Py_GIL_DISABLED` matches the build, installs `packaging` from the backend lock with its hash, and runs `test_python_abi_probe.py` on that interpreter. `probe_python_abi.py packages` then records the interpreter's ABI facts and, for each native package in the backend lock (`autobahn`, `cbor2`, `cffi`, `cryptography`, `greenlet`, `msgpack`, `psycopg-binary`, `pydantic-core`, `ujson`, and `zope-interface`), the wheels PyPI lists for the locked version in each of the five ABI categories, which one this interpreter installs from the lock (binary only, with the lock's hashes, together with the lock packages it needs and no others), and an import and a call in a fresh process with the GIL state before and after. The record's install stage comes from that report, and `initialize`, `execute`, and `correctness` are `not-run`: the backend is not run on CPython 3.15. The job fails when an install fails with a wheel in the lock, an import or call fails, a package enables the GIL in a build that started without it, or the import process exits with a nonzero status or a signal after printing its result. | + +- The platform and UBI artifacts are `compatibility--attempt-`, holding the record, `wheels.json`, `requirements.json` when the install got that far, `install.log`, and `django-tests.log` when the tests ran; the UBI artifacts add `system-python.json`, `install-stage.txt`, and `summary.md`. The Python ABI artifacts are `python-abi---attempt-`, holding the record and `python-abi.json`. +- The probe asks `https://pypi.org/pypi///json` for each pinned release, with a timeout and a size limit, and refuses metadata for another release or with a malformed file name or digest; what it installs still comes from the lock's hashes. +- The first run of both workflows was on pull request #19, head `f7ccd3da0baf467bc6d336a946fd5a4d38107e5d`, base `5f27b1c23869e2afe7ba7cefb04154d296e1d6ad`, merge commit `aaaa80c1c0dcd2cabd0318f6ca29255bb540796b`: runs [38210201779](https://github.com/P-R-N-D/Claus-AI/actions/runs/38210201779) (`compatibility.yml`, 11 of 11 jobs passed) and [38210201628](https://github.com/P-R-N-D/Claus-AI/actions/runs/38210201628) (`python-abi.yml`, 12 of 12 passed). [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#observed-lane-results-2026-10-12) records what they observed. Cite the run that tested the commit in question, not this one. + ## API collection testing Blocked, recorded 2026-10-10. No Postman collection, Postman CLI, or Newman is in the repository. The Postman CLI was checked as a candidate for the route checks above, in an isolated scratch install (`npm install --ignore-scripts`, a throwaway `HOME`, no login, API key, `postman init`, or cloud workspace) of `postman-cli` 1.73.0 and its `@postman/pm-bin-linux-x64` 1.73.0 binary package. Two conditions set for adopting it were not met: @@ -230,6 +259,8 @@ Do not issue state-changing requests against production without explicit approva The architecture already requires free-threading compatibility. [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md) defines Python 3.15 Limited API/`abi3t` artifact selection, the dated wheel gaps, and performance admission budgets. These qualification checks and benchmarks are planned; the current test suite does not establish Python 3.15/3.15t support. +Since 2026-10-12, `python-abi.yml` (see "Compatibility and Python ABI workflows" above) covers part of the first three items below on six GitHub-hosted runners: the interpreter's `Py_GIL_DISABLED`, SOABI, and extension suffixes; for each native package in the backend lock, which of the five ABI categories PyPI publishes for the locked version and which this interpreter accepts; a binary-only install from the lock's hashes; and the GIL state before and after one import and one call in a fresh process, with automatic re-enablement recorded rather than forced off, and failing the job. It runs against the CPython 3.12 lock, not a lock resolved for 3.15 or 3.15t, does not run the backend, the ASGI servers, or any workload on 3.15, and does not test thread safety. The remaining items stay planned, and the probe does not establish Python 3.15/3.15t support. + When a suitable environment and dependency set are available: - Use separate environments for the existing CPython 3.12 lane, regular `cp315`, and free-threaded `cp315t`, with each lane's resolved lock, platform (OS, CPU, libc, and accelerator, if any), artifacts, and installer versions recorded. Each platform lane in [DEPENDENCY-STRATEGY.md](DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes) qualifies separately. diff --git a/scripts/ci/collect_environment.py b/scripts/ci/collect_environment.py index c99a00e..6e6b13f 100644 --- a/scripts/ci/collect_environment.py +++ b/scripts/ci/collect_environment.py @@ -7,6 +7,13 @@ /run/systemd/system exist. - The first 20 bytes of the interpreter's executable (its ELF header), when the build architecture is not otherwise known. +- On macOS, these sysctl values: kern.hv_vmm_present, hw.model, + kern.osproductversion, kern.osrelease, hw.optional.arm64, hw.optional.x86_64 + and sysctl.proc_translated. +- On Windows, platform.version(), the native machine IsWow64Process2 names, + the SystemManufacturer and SystemProductName values under + HKLM\\HARDWARE\\DESCRIPTION\\System\\BIOS, and whether this process's token + is elevated, holds the Administrators group enabled, or is LocalSystem. - The environment variables in ENV_ALLOWLIST. - The pull request head and base in the GitHub Actions event file. - The checkout's .git/HEAD, and the loose ref or packed-refs line it points to. @@ -15,6 +22,8 @@ the PATH directories only, so on Windows not in the current directory unless PATH names it, also when PATH is unset, and there only as node.exe, whatever PATHEXT lists. +- The JSON object files that --extension names, which go into the record's + extensions unchanged. Files are read only when they are regular files, and are opened so that a FIFO cannot block the probe. @@ -24,11 +33,16 @@ directory, network address or other environment variable. It refuses --stage values, and does not write a record, that look like a token or a private key. -On Linux it fills in the layers, identity and isolation facts. On other systems -it records the OS family, the user and group IDs on FreeBSD (os.getresuid) -and macOS (os.geteuid, os.getuid), the Python and Node runtimes and the run, -and leaves the layer kind and the isolation facts unknown, so the -least-privilege policy fails there until a probe measures them. +On Linux it fills in the layers, identity and isolation facts. On macOS and +Windows it records the OS, the machine's architecture, whether the system runs +in a VM, whether each runtime is emulated, and who the workload runs as (on +Windows, whether its token is an administrator's); the Linux isolation +interfaces are not-applicable there, except that on Windows the capabilities +are unknown: the token's privileges, their counterpart, are not read, so a +Windows record does not pass minimal-capabilities. On other systems it records the OS +family, the user and group IDs on FreeBSD (os.getresuid), the Python and Node +runtimes and the run, and leaves the layer kind and the isolation facts +unknown, so the least-privilege policy fails there until a probe measures them. Each value it records is marked in one of these ways: @@ -44,11 +58,22 @@ detect stage. The other probes' values stay in place, so a record can show which part of detection failed. +Inside a container the engine's own facts (rootless or not, privileged, +devices) are not visible. On the host, --amend-container RECORD +--podman-container NAME runs `podman info` and `podman container inspect NAME`, +found in the PATH directories and run with this process's environment, which +rootless Podman needs to find its storage, and puts those three facts into the +innermost layer of a record that was taken inside that running container. It +changes nothing else in the record, and writes nothing if podman fails. + Standard library only. Usage, from the repository root: python -I scripts/ci/collect_environment.py --output result.json [--profile-id ID] [--summary FILE] [--no-node] [--stage NAME=STATUS] [--stage-reason NAME=TEXT] [--stage-evidence NAME=REF] [--stage-escalation NAME=VALUE] + [--extension x-NAME=PATH] + python -I scripts/ci/collect_environment.py --amend-container RECORD + --podman-container NAME The --stage options record what other steps of the job did in the stages after detect. Every stage they do not name is not-run. @@ -58,7 +83,8 @@ - 1: the record was written but a probe failed. - 2: a usage error, or the record or the summary could not be written. The record is written first, and the message says which one failed. A record - that looks like it holds a secret is not written. + that looks like it holds a secret is not written. With --amend-container, + any failure, and the record is left as it was. """ import argparse @@ -175,6 +201,19 @@ NODE_EXPRESSION = "JSON.stringify({v:process.versions.node,m:process.versions.modules,n:process.versions.napi||null,a:process.arch,p:process.platform,e:require('os').endianness()})" NODE_OUTPUT_LIMIT = 4096 NODE_TIMEOUT = 30 +# The image file machine types IsWow64Process2 reports for the machine that runs Windows. +WINDOWS_MACHINES = {0x014C: "x86", 0x01C4: "armv7", 0x8664: "x86_64", 0xAA64: "aarch64"} +WINDOWS_BIOS_KEY = "HARDWARE\\DESCRIPTION\\System\\BIOS" +WINDOWS_BIOS_EVIDENCE = "python:winreg:HKLM\\HARDWARE\\DESCRIPTION\\System\\BIOS" +# hw.model prefixes of macOS virtual machines. +MACOS_MODELS = (("VirtualMac", "apple-virtualization"), ("VMware", "vmware"), ("Parallels", "parallels")) +# How a record names a device an engine passes in: the path under /dev, with "/" as "-" and in lower case. +DEVICE = re.compile(r"/dev/([A-Za-z0-9][A-Za-z0-9._+/-]{0,62})") +EXTENSION_NAME = re.compile(r"x-[a-z0-9][a-z0-9-]{0,62}") +EXTENSION_LIMIT = 1024 * 1024 +RECORD_LIMIT = 8 * 1024 * 1024 +PODMAN_TIMEOUT = 120 +PODMAN_OUTPUT_LIMIT = 8 * 1024 * 1024 def observed(value, *evidence): @@ -287,15 +326,132 @@ def read_regular(path, limit): return handle.read(limit) +class NativeQueries: + """Reads that need an operating system's own interface, through ctypes and winreg. + + On macOS, sysctlbyname(3); on Windows, the process token, IsWow64Process2 and two + BIOS values in the registry. Each method returns None when the interface is + missing or the call fails, so the fact it feeds stays unknown. Nothing here + writes, starts a process or changes privileges. Tests replace this class. + """ + + def sysctl(self, name, integer): + """A sysctl value: an int when `integer`, else a string of at most 255 bytes.""" + import ctypes + + libc = ctypes.CDLL(None, use_errno=True) + function = libc.sysctlbyname + function.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t] + function.restype = ctypes.c_int + size = ctypes.c_size_t(0) + if function(name.encode("ascii"), None, ctypes.byref(size), None, 0) != 0 or not 0 < size.value <= 256: + return None + buffer = ctypes.create_string_buffer(size.value) + if function(name.encode("ascii"), buffer, ctypes.byref(size), None, 0) != 0: + return None + raw = buffer.raw[:size.value] + if integer: + return int.from_bytes(raw, sys.byteorder) if len(raw) in (4, 8) else None + text = raw.split(b"\0", 1)[0] + return text.decode("ascii") if text.isascii() else None + + def windows_native_machine(self): + """The image file machine type of the machine that runs Windows, from IsWow64Process2 (Windows 10 1709 and later).""" + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + function = getattr(kernel32, "IsWow64Process2", None) + if function is None: + return None + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + function.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.USHORT), ctypes.POINTER(wintypes.USHORT)] + function.restype = wintypes.BOOL + process, native = wintypes.USHORT(), wintypes.USHORT() + if not function(kernel32.GetCurrentProcess(), ctypes.byref(process), ctypes.byref(native)): + return None + return native.value + + def windows_token(self): + """Whether this process's token is elevated, holds the Administrators group enabled, or is LocalSystem. + + Each value is True, False, or None when it could not be read. CheckTokenMembership counts an + enabled group only, so the deny-only Administrators group of a UAC-filtered token is not a membership. + """ + import ctypes + from ctypes import wintypes + + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + advapi32.GetTokenInformation.restype = wintypes.BOOL + advapi32.CreateWellKnownSid.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD)] + advapi32.CreateWellKnownSid.restype = wintypes.BOOL + advapi32.CheckTokenMembership.argtypes = [wintypes.HANDLE, ctypes.c_void_p, ctypes.POINTER(wintypes.BOOL)] + advapi32.CheckTokenMembership.restype = wintypes.BOOL + advapi32.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + advapi32.EqualSid.restype = wintypes.BOOL + token = wintypes.HANDLE() + if not advapi32.OpenProcessToken(kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token)): # TOKEN_QUERY + return None + + def well_known(kind): + size = wintypes.DWORD(68) # SECURITY_MAX_SID_SIZE + sid = ctypes.create_string_buffer(68) + return sid if advapi32.CreateWellKnownSid(kind, None, sid, ctypes.byref(size)) else None + + try: + elevation, size = wintypes.DWORD(), wintypes.DWORD() + # TokenElevation (20) gives TOKEN_ELEVATION, whose only member is TokenIsElevated. + elevated = bool(elevation.value) if advapi32.GetTokenInformation(token, 20, ctypes.byref(elevation), ctypes.sizeof(elevation), ctypes.byref(size)) else None + administrators, member = well_known(26), wintypes.BOOL() # WinBuiltinAdministratorsSid + # A NULL token checks this thread's effective token, which is the process token here. + in_administrators = bool(member.value) if administrators is not None and advapi32.CheckTokenMembership(None, administrators, ctypes.byref(member)) else None + local_system, system_sid = None, well_known(22) # WinLocalSystemSid + advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) # TokenUser (1): asks for the size + if system_sid is not None and 0 < size.value <= 1024: + buffer = ctypes.create_string_buffer(size.value) + if advapi32.GetTokenInformation(token, 1, buffer, size, ctypes.byref(size)): + # TOKEN_USER starts with SID_AND_ATTRIBUTES, which starts with the pointer to the user's SID. + local_system = bool(advapi32.EqualSid(ctypes.c_void_p.from_buffer(buffer).value, system_sid)) + finally: + kernel32.CloseHandle(token) + return {"elevated": elevated, "administrators": in_administrators, "local_system": local_system} + + def windows_bios(self): + """SystemManufacturer and SystemProductName under HKLM\\HARDWARE\\DESCRIPTION\\System\\BIOS, which Windows fills from SMBIOS.""" + import winreg + + values = {} + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, WINDOWS_BIOS_KEY) as key: + for name in ("SystemManufacturer", "SystemProductName"): + try: + value, kind = winreg.QueryValueEx(key, name) + except OSError: + continue + if kind == winreg.REG_SZ and isinstance(value, str): + values[name] = value[:256] + except OSError: + return None + return values + + class Probe: - """Reads one system. `root`, `env` and `git_dir` exist so tests can point it at a made-up one.""" + """Reads one system. `root`, `env`, `git_dir` and `native` exist so tests can point it at a made-up one.""" - def __init__(self, root="/", env=None, git_dir=None, system=None, probe_node=True): + def __init__(self, root="/", env=None, git_dir=None, system=None, probe_node=True, native=None): self.root = pathlib.Path(root) self._env = os.environ if env is None else env self.git_dir = pathlib.Path(git_dir) if git_dir else ROOT / ".git" self.system = system or platform.system() self.probe_node = probe_node + self.native = native or NativeQueries() + self._hardware = None self.errors = [] # -- inputs ------------------------------------------------------------ @@ -496,6 +652,10 @@ def namespace_inode(self, name): # -- sections ---------------------------------------------------------- def layers(self): + if self.system == "Darwin": + return self.with_host(self.macos_layer()) + if self.system == "Windows": + return self.with_host(self.windows_layer()) if self.system != "Linux": family = OS_FAMILIES.get(self.system) reason = f"this probe reads Linux interfaces only; this system is {self.system or 'unidentified'}" @@ -634,6 +794,148 @@ def layers(self): layers.append(order_layer(container)) return layers + # -- macOS and Windows ------------------------------------------------- + + def hardware_arch(self): + """(architecture of the machine, evidence) on macOS and Windows, or (None, None). + + Both are read from the system, not from this process, so an emulated process + still names the machine: on macOS hw.optional.arm64 is 1 on Apple silicon, + also to a process that Rosetta 2 translates; on Windows IsWow64Process2 + names the native machine, also to an x64 process emulated on Arm. + """ + if self._hardware is None: + arch, evidence = None, None + if self.system == "Darwin": + if self.native.sysctl("hw.optional.arm64", True) == 1: + arch, evidence = "aarch64", "python:sysctlbyname(hw.optional.arm64)" + elif self.native.sysctl("hw.optional.x86_64", True) == 1: + arch, evidence = "x86_64", "python:sysctlbyname(hw.optional.x86_64)" + elif self.system == "Windows": + arch = WINDOWS_MACHINES.get(self.native.windows_native_machine()) + evidence = "python:IsWow64Process2#nativeMachine" if arch else None + self._hardware = (arch, evidence) + return self._hardware + + def hardware_execution(self, arch, arch_evidence): + """native or emulated, comparing a program's architecture with the machine's (macOS and Windows).""" + hardware, evidence = self.hardware_arch() + if arch is None: + return unknown("the architecture is not known") + if hardware is None: + return unknown("the machine's architecture could not be read") + arch_evidence = [arch_evidence] if isinstance(arch_evidence, str) else list(arch_evidence) + if arch == hardware or (arch, hardware) in COMPATIBLE: + return inferred("native", *arch_evidence, evidence) + if {arch, hardware} <= KNOWN_ARCHES: + # Neither system runs another architecture's code except through an emulator (Rosetta 2, or Windows on Arm's x64 emulation). + return inferred("emulated", *arch_evidence, evidence) + return unknown(f"{arch} on a {hardware} machine is outside the documented architectures") + + def with_host(self, layer): + """The layer, after an inferred physical host when the layer is a VM, as on Linux.""" + layer = order_layer(layer) + if known_kind(layer) != "vm": + return [layer] + host_reason = "a guest cannot see the physical host" + return [order_layer({ + "kind": inferred("host", *layer["kind"]["evidence"]), + "technology": not_applicable("the outermost host runs on hardware"), + "os": {"family": unknown(host_reason), "name": unknown(host_reason), "version": unknown(host_reason), "kernel": unknown(host_reason)}, + "cpu": {"arch": unknown(host_reason), "execution": unknown(host_reason)}, + "service_manager": {"name": unknown(host_reason), "scope": unknown(host_reason)}, + }), layer] + + def machine_cpu(self): + arch, evidence = self.hardware_arch() + return { + "arch": observed(arch, evidence) if arch else unknown("the machine's architecture could not be read"), + "execution": unknown("whether the layer below emulates this CPU is not visible from inside it"), + } + + def macos_layer(self): + vmm = self.native.sysctl("kern.hv_vmm_present", True) + model = self.native.sysctl("hw.model", False) + layer = {} + if vmm == 1: + layer["kind"] = inferred("vm", "python:sysctlbyname(kern.hv_vmm_present)") + named = [technology for prefix, technology in MACOS_MODELS if isinstance(model, str) and model.startswith(prefix)] + layer["technology"] = inferred(named[0], "python:sysctlbyname(hw.model)") if named else unknown("hw.model names no known hypervisor") + else: + reason = "kern.hv_vmm_present is 0: no hypervisor signal; that does not show bare metal" if vmm == 0 else "kern.hv_vmm_present cannot be read" + layer["kind"] = unknown(reason) + layer["technology"] = unknown(reason) + version = self.native.sysctl("kern.osproductversion", False) + release = self.native.sysctl("kern.osrelease", False) + layer["os"] = { + "family": observed("macos", "python:platform.system"), + "name": observed("macos", "python:platform.system"), + "version": observed(version, "python:sysctlbyname(kern.osproductversion)") if isinstance(version, str) and re.fullmatch(r"[0-9]{1,4}(\.[0-9]{1,4}){0,2}", version) else unknown("kern.osproductversion gives no usable version"), + "kernel": observed(release, "python:sysctlbyname(kern.osrelease)") if isinstance(release, str) and PRINTABLE.fullmatch(release) else unknown("kern.osrelease gives no usable release"), + } + layer["cpu"] = self.machine_cpu() + reason = "the service manager is probed on Linux only" + layer["service_manager"] = {"name": unknown(reason), "scope": unknown(reason)} + return layer + + def windows_layer(self): + bios = self.native.windows_bios() + layer = {} + vendor, product = (bios or {}).get("SystemManufacturer", ""), (bios or {}).get("SystemProductName", "") + named = None + for name, text in (("SystemManufacturer", vendor), ("SystemProductName", product)): + for prefix, technology in DMI_VENDORS: + if named is None and text.startswith(prefix): + named = technology, f"{WINDOWS_BIOS_EVIDENCE}#{name}" + if named is None and (vendor, product) == ("Microsoft Corporation", "Virtual Machine"): + # Hyper-V, Azure among it, as systemd-detect-virt reads the same SMBIOS values. + named = "hyper-v", f"{WINDOWS_BIOS_EVIDENCE}#SystemProductName" + if named: + layer["kind"] = inferred("vm", named[1]) + layer["technology"] = inferred(named[0], named[1]) + else: + reason = "the BIOS values cannot be read" if bios is None else "the BIOS values name no known hypervisor; that does not show bare metal" + layer["kind"] = unknown(reason) + layer["technology"] = unknown(reason) + version = platform.version() + version_fact = observed(version, "python:platform.version") if re.fullmatch(r"[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}", version or "") else unknown("platform.version() gives no usable version") + layer["os"] = { + "family": observed("windows", "python:platform.system"), + "name": observed("windows", "python:platform.system"), + "version": version_fact, + # The NT kernel carries the build of the system it ships with. + "kernel": json.loads(json.dumps(version_fact)), + } + layer["cpu"] = self.machine_cpu() + reason = "the service manager is probed on Linux only" + layer["service_manager"] = {"name": unknown(reason), "scope": unknown(reason)} + return layer + + def windows_superuser(self): + """Whether the process holds an elevated token, the enabled Administrators group, or runs as LocalSystem.""" + token = self.native.windows_token() + if token is None: + return unknown("the process token could not be opened") + evidence = { + "elevated": "python:GetTokenInformation(TokenElevation)", + "administrators": "python:CheckTokenMembership(BUILTIN\\Administrators)", + "local_system": "python:GetTokenInformation(TokenUser)", + } + held = [evidence[name] for name in evidence if token.get(name) is True] + if held: + return observed(True, *held) + if all(token.get(name) is False for name in evidence): + return observed(False, *evidence.values()) + return unknown("part of the process token could not be read, and what was read shows no administrator") + + def macos_execution(self, arch, arch_evidence): + """sysctl.proc_translated is 1 in a process Rosetta 2 translates and 0 in a native one; Intel Macs, which have no Rosetta, lack it.""" + translated = self.native.sysctl("sysctl.proc_translated", True) + arch_evidence = [arch_evidence] if isinstance(arch_evidence, str) else list(arch_evidence) + if translated in (0, 1) and arch is not None: + return inferred("emulated" if translated else "native", *arch_evidence, "python:sysctlbyname(sysctl.proc_translated)") + return self.hardware_execution(arch, arch_evidence) + def kernel_release(self): """The release the kernel reports in /proc; uname, which personality changes and qemu-user can rewrite, only as a fallback.""" text, _ = self.read("/proc/sys/kernel/osrelease", 256) @@ -679,11 +981,14 @@ def identity(self): ids = {"uid": observed(uid, source), "real_uid": observed(real_uid, source), "saved_uid": observed(saved_uid, source)} gid_fact = observed(gid, "file:/proc/self/status#Gid") elif self.system == "Windows": - # Whether a Windows process holds an elevated token is not probed, so superuser stays unknown. - identity = identity_fallback("this probe reads POSIX user IDs only") - identity["real_uid"] = not_applicable("Windows has no POSIX user IDs") - identity["saved_uid"] = not_applicable("Windows has no POSIX user IDs") - return identity + # The superuser on Windows holds an elevated token, the Administrators group, or is LocalSystem (docs/CI-MATRIX.md). + reason = "Windows has no POSIX user or group IDs" + namespace = "user namespaces are a Linux interface" + return { + "superuser": self.windows_superuser(), + "uid": not_applicable(reason), "real_uid": not_applicable(reason), "saved_uid": not_applicable(reason), "gid": not_applicable(reason), + "user_namespace": {"initial": not_applicable(namespace), "uid_map": not_applicable(namespace)}, + } elif self.system == "FreeBSD" and hasattr(os, "getresuid"): real_uid, uid, saved_uid = os.getresuid() source = "python:os.getresuid" @@ -720,6 +1025,15 @@ def identity(self): return identity def isolation(self): + if self.system == "Darwin": + return isolation_not_applicable("a Linux interface; not applicable on macOS") + if self.system == "Windows": + isolation = isolation_not_applicable("a Linux interface; not applicable on Windows") + # A Windows token holds privileges, such as SeDebugPrivilege, in place of capabilities. They are not read, so + # the policy cannot be shown to hold: unknown, not not-applicable (docs/CI-MATRIX.md). + reason = "Windows token privileges, the counterpart of Linux capabilities, are not read" + isolation["capabilities"] = {"effective": unknown(reason), "permitted": unknown(reason), "bounding": unknown(reason)} + return isolation if self.system != "Linux": return isolation_fallback("this probe reads Linux interfaces only") fields, reason = self.status_fields() @@ -902,9 +1216,19 @@ def python(self, reference): "free_threaded_build": free_threaded, "gil_enabled": gil, "libc": observed(libc, "python:os.confstr") if isinstance(libc, str) and re.fullmatch(r"glibc [0-9]{1,4}\.[0-9]{1,4}(\.[0-9]{1,4})?", libc) else unknown("the C library reports no version through os.confstr; only glibc does"), - "execution": execution_fact(arch, arch_evidence, reference) if self.system == "Linux" else unknown("compared on Linux only"), + "execution": self.execution(arch, arch_evidence, reference, python=True), } + def execution(self, arch, arch_evidence, reference, python=False): + if self.system == "Linux": + return execution_fact(arch, arch_evidence, reference) + if self.system == "Darwin": + # Rosetta 2 tells a process whether it translates that process; node runs in another one. + return self.macos_execution(arch, arch_evidence) if python else self.hardware_execution(arch, arch_evidence) + if self.system == "Windows": + return self.hardware_execution(arch, arch_evidence) + return unknown("compared on Linux, macOS and Windows only") + def node(self, reference): def all_unknown(reason): return {name: unknown(reason) for name in ("version", "modules_abi", "napi_version", "platform", "arch", "execution")} @@ -1003,7 +1327,7 @@ def checked(key, pattern): "napi_version": checked("n", r"[0-9]{1,5}"), "platform": observed(platform_name, evidence) if isinstance(platform_name, str) and SLUG.fullmatch(platform_name) else unknown("node reported no usable platform"), "arch": arch_fact, - "execution": execution_fact(arch, arch_evidence, reference) if self.system == "Linux" else unknown("compared on Linux only"), + "execution": self.execution(arch, arch_evidence, reference), } def run(self): @@ -1227,6 +1551,18 @@ def isolation_fallback(reason): } +def isolation_not_applicable(reason): + return { + "no_new_privs": not_applicable(reason), + "capabilities": {"effective": not_applicable(reason), "permitted": not_applicable(reason), "bounding": not_applicable(reason)}, + "seccomp": not_applicable(reason), + "apparmor": {"enabled": not_applicable(reason), "profile": not_applicable(reason)}, + "selinux": {"mode": not_applicable(reason), "context": not_applicable(reason)}, + "cgroup": {"version": not_applicable(reason), "delegated": not_applicable(reason)}, + "pid_namespace": {"initial": not_applicable(reason)}, + } + + def python_fallback(reason): names = ("implementation", "version", "cache_tag", "soabi", "platform", "arch", "pointer_bits", "free_threaded_build", "gil_enabled", "libc", "execution") return {name: unknown(reason) for name in names} @@ -1286,6 +1622,98 @@ def build_result(probe, profile_id=None, stage_args=None, now=None): return result +def read_extensions(values): + """{x-name: JSON object} from --extension x-NAME=PATH options; raise ValueError on bad input.""" + extensions = {} + for value in values or []: + name, sep, path = value.partition("=") + if not sep or not EXTENSION_NAME.fullmatch(name) or not path: + raise ValueError("--extension takes x-NAME=PATH, with NAME in lower case") + if name in extensions: + raise ValueError(f"--extension {name} is given twice") + try: + data = read_regular(path, EXTENSION_LIMIT + 1) + except OSError as exc: + raise ValueError(f"--extension {name}: cannot read the file ({type(exc).__name__})") from None + if len(data) > EXTENSION_LIMIT: + raise ValueError(f"--extension {name}: the file is larger than {EXTENSION_LIMIT} bytes") + try: + document = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise ValueError(f"--extension {name}: not a UTF-8 JSON document") from None + if not isinstance(document, dict): + raise ValueError(f"--extension {name}: not a JSON object") + extensions[name] = document + return extensions + + +def run_podman(executable, args): + """The JSON a podman command prints; raise ProbeError when it fails, times out, or prints too much.""" + try: + process = subprocess.run([executable, *args], stdin=subprocess.DEVNULL, capture_output=True, timeout=PODMAN_TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ProbeError(f"podman {args[0]} could not be run ({type(exc).__name__})") from None + if process.returncode != 0: + raise ProbeError(f"podman {' '.join(args[:2])} exited with status {process.returncode}") + if len(process.stdout) > PODMAN_OUTPUT_LIMIT: + raise ProbeError(f"podman {args[0]} printed more than {PODMAN_OUTPUT_LIMIT} bytes") + try: + return json.loads(process.stdout.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise ProbeError(f"podman {args[0]} did not print JSON") from None + + +def device_names(devices): + """Record names for HostConfig.Devices entries, or None when one cannot be named.""" + names = [] + for device in devices: + path = device.get("PathInContainer") if isinstance(device, dict) else None + match = DEVICE.fullmatch(path) if isinstance(path, str) else None + name = match.group(1).replace("/", "-").lower() if match else None + if name is None or not SLUG.fullmatch(name): + return None + names.append(name) + return sorted(set(names)) + + +def engine_facts(info, inspect, container): + """The engine-side container facts from `podman info` and `podman container inspect` output; raise ProbeError.""" + rootless = info.get("host", {}).get("security", {}).get("rootless") if isinstance(info, dict) else None + if not isinstance(rootless, bool): + raise ProbeError("podman info has no boolean host.security.rootless") + if not isinstance(inspect, list) or len(inspect) != 1 or not isinstance(inspect[0], dict): + raise ProbeError(f"podman container inspect does not describe exactly one container named {container}") + entry = inspect[0] + if entry.get("Name") != container: + raise ProbeError(f"podman container inspect names another container than {container}") + if not isinstance(entry.get("State"), dict) or entry["State"].get("Running") is not True: + raise ProbeError(f"container {container} is not running, so it may not be the one the record was taken in") + host = entry.get("HostConfig") + if not isinstance(host, dict) or not isinstance(host.get("Privileged"), bool): + raise ProbeError("podman container inspect has no boolean HostConfig.Privileged") + devices = host.get("Devices") + if devices is not None and not isinstance(devices, list): + raise ProbeError("podman container inspect has a HostConfig.Devices that is not a list") + names = device_names(devices or []) + inspect_ref = "cmd:podman container inspect" + return { + "engine_mode": observed("rootless" if rootless else "rootful", "cmd:podman info#host.security.rootless"), + "privileged": observed(host["Privileged"], f"{inspect_ref}#HostConfig.Privileged"), + "devices": observed(names, f"{inspect_ref}#HostConfig.Devices") if names is not None else unknown("a device path does not have the expected form", f"{inspect_ref}#HostConfig.Devices"), + } + + +def amend_container(record, info, inspect, container): + """Put the engine-side facts into the innermost layer of a record taken inside that container.""" + if not isinstance(record, dict) or record.get("kind") != "result" or record.get("collector") != COLLECTOR: + raise ProbeError("the file is not a result this collector wrote") + layers = record.get("environment", {}).get("layers") + if not isinstance(layers, list) or not layers or not isinstance(layers[-1], dict) or known_kind(layers[-1]) != "container" or not isinstance(layers[-1].get("container"), dict): + raise ProbeError("the record's innermost layer is not a container") + layers[-1]["container"].update(engine_facts(info, inspect, container)) + return record + + def secret_shape(text): """The label of the first secret shape that `text` matches, or None.""" for label, shape in SECRET_SHAPES: @@ -1401,7 +1829,10 @@ def same_file(path, other): def main(argv=None): parser = ArgumentParser(description=__doc__.split("\n\n", 1)[0]) - parser.add_argument("--output", required=True, type=pathlib.Path, help="where to write the result JSON") + parser.add_argument("--output", type=pathlib.Path, help="where to write the result JSON") + parser.add_argument("--extension", action="append", metavar="x-NAME=PATH", help="add a JSON object file to the record's extensions") + parser.add_argument("--amend-container", type=pathlib.Path, metavar="RECORD", help="on the host, add Podman's facts about --podman-container to a record taken inside it, and rewrite it") + parser.add_argument("--podman-container", metavar="NAME", help="the running container that --amend-container describes") parser.add_argument("--profile-id", help="id of the profile this lane declares") parser.add_argument("--summary", type=pathlib.Path, help="append a Markdown summary to this file") parser.add_argument("--no-node", action="store_true", help="do not run node") @@ -1410,14 +1841,23 @@ def main(argv=None): parser.add_argument("--stage-evidence", action="append", metavar="NAME=REF") parser.add_argument("--stage-escalation", action="append", metavar="NAME=VALUE") args = parser.parse_args(argv) + if args.amend_container is not None: + return amend_main(parser, args) + if args.output is None: + parser.error("--output is required") + if args.podman_container is not None: + parser.error("--podman-container goes with --amend-container") if args.profile_id is not None and not SLUG.fullmatch(args.profile_id): parser.error("--profile-id must be a lower-case identifier") try: stage_args = parse_stage_args(args) + extensions = read_extensions(args.extension) except ValueError as exc: parser.error(str(exc)) result = build_result(Probe(probe_node=not args.no_node), args.profile_id, stage_args) + if extensions: + result["extensions"] = extensions text = json.dumps(result, indent=2, ensure_ascii=True) + "\n" if secret_shape(text): print(f"error: the record holds a value that looks like a {secret_shape(text)}; it was not written", file=sys.stderr) @@ -1445,5 +1885,39 @@ def main(argv=None): return 0 if detect == "passed" else 1 +def amend_main(parser, args): + others = [name for name in ("output", "profile_id", "summary", "stage", "stage_reason", "stage_evidence", "stage_escalation", "extension") if getattr(args, name)] + if others or args.no_node: + parser.error("--amend-container takes --podman-container only") + if args.podman_container is None or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", args.podman_container): + parser.error("--amend-container needs --podman-container NAME, a container name") + # podman is found as node is, in the PATH directories only, and PATH is read through Probe.env as node's is. + search_path = Probe(probe_node=False).env("PATH") or os.pathsep.join(entry for entry in os.defpath.split(os.pathsep) if entry != os.curdir) + executable = find_on_path("podman", search_path) + try: + if executable is None: + raise ProbeError("podman is not on PATH") + record = json.loads(read_regular(args.amend_container, RECORD_LIMIT).decode("utf-8")) + info = run_podman(executable, ["info", "--format", "json"]) + inspect = run_podman(executable, ["container", "inspect", "--format", "json", args.podman_container]) + record = amend_container(record, info, inspect, args.podman_container) + except (OSError, UnicodeDecodeError, ValueError, ProbeError) as exc: + message = str(exc) if isinstance(exc, ProbeError) else f"cannot read the record ({type(exc).__name__})" + print(plain(f"error: {message}; the record was not changed"), file=sys.stderr) + return 2 + text = json.dumps(record, indent=2, ensure_ascii=True) + "\n" + if secret_shape(text): + print(f"error: the record would hold a value that looks like a {secret_shape(text)}; it was not changed", file=sys.stderr) + return 2 + try: + args.amend_container.write_text(text, encoding="utf-8") + except OSError as exc: + print(f"error: cannot write the record: {type(exc).__name__}", file=sys.stderr) + return 2 + container = record["environment"]["layers"][-1]["container"] + print(plain(f"{args.amend_container.name}: engine {describe(container['engine_mode'])}, privileged {describe(container['privileged'])}, devices {describe(container['devices'])}")) + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/ci/probe_python_abi.py b/scripts/ci/probe_python_abi.py new file mode 100644 index 0000000..f632d9c --- /dev/null +++ b/scripts/ci/probe_python_abi.py @@ -0,0 +1,787 @@ +"""Probe how this Python interpreter takes the backend lock's wheels (docs/CI-MATRIX.md, docs/DEPENDENCY-STRATEGY.md). + +Run it with the interpreter under test, from a virtual environment that holds +packaging from the lock (it reads wheel tags with packaging.tags, as pip does): + + python -I scripts/ci/probe_python_abi.py wheels --lock LOCK --output FILE + python -I scripts/ci/probe_python_abi.py requirements --lock LOCK --output FILE + python -I scripts/ci/probe_python_abi.py packages --lock LOCK --uv UV --output FILE + python -I scripts/ci/probe_python_abi.py install-stage --sync STATUS --check STATUS --log FILE --wheels FILE [--requirements FILE] + python -I scripts/ci/probe_python_abi.py pin --lock LOCK --name NAME --output FILE + +wheels: for every package the lock pins, the published wheels of that version this + interpreter can install, and whether the lock holds the hash of one of them. A + package with none is "uncovered": the lock cannot be installed binary-only here. +requirements: the requirements of the installed distributions that are not + installed, or whose installed version they exclude, as uv pip check reports + them, and whether the lock pins each one. +packages: for each package in TARGETS, the wheel tags published for this + interpreter's platform, which of them it accepts, then an install with uv from + the lock's hashes, binary only, the selected wheel's file name and SHA-256, the + extension modules it installed, and, in a new process, the GIL state before and + after the import, the import, one call, and how the process exited (a nonzero + status or a signal fails the package, whatever it printed). It does not test + thread safety. +install-stage: the status of a lane's install stage, from the exit statuses of + uv pip sync and uv pip check, the installer's log, and the wheels and + requirements reports: blocked when the lock lacks a wheel the installer names + or a requirement this platform needs and uv pip check reports nothing else, + failed for any other failure or when the install contradicts a report, passed + when both commands passed. It exits 1 when the stage failed. +pin: the lock's lines, with their hashes, for the packages named, so that one + of them (packaging) can be installed alone, binary only and hash-checked. + +It reads the lock, the metadata of installed distributions, and PyPI's JSON API +for each pinned version (https://pypi.org/pypi/NAME/VERSION/json, or the files +--metadata-dir names); that is its only network use, and it sends nothing but +the package name and version. packages also runs uv, which downloads from PyPI +and installs into this interpreter's environment. It writes the output file, +the optional summary and GitHub output lines, and, for packages, a temporary +requirements file. It records no host name, account name, home directory or +environment variable, and never escalates privileges. + +Exit status: 0 when nothing unexpected happened (a missing wheel is expected, +and is reported); 1 when an install, import or call failed unexpectedly, or a +report contradicts itself; 2 on a usage error or when the output cannot be +written or the metadata cannot be read. +""" + +import argparse +import hashlib +import importlib +import importlib.machinery +import importlib.metadata +import json +import os +import pathlib +import platform +import re +import signal +import subprocess +import sys +import sysconfig +import tempfile +import time +import urllib.request + +try: + from packaging import markers, requirements, specifiers, tags, utils, version as versions +except ImportError: # Reported by main(); the functions below that need it are not reached. + markers = requirements = specifiers = tags = utils = versions = None + +SCHEMA = "claus-python-abi/1" +PYPI = "https://pypi.org/pypi/{name}/{version}/json" +METADATA_LIMIT = 16 * 1024 * 1024 +FETCH_TIMEOUT = 30 +FETCH_ATTEMPTS = 3 +UV_TIMEOUT = 600 +PROBE_TIMEOUT = 120 +NAME = re.compile(r"[a-z0-9]+(-[a-z0-9]+)*") +VERSION = re.compile(r"[0-9A-Za-z.+!_-]{1,64}") +SHA256 = re.compile(r"[0-9a-f]{64}") +WHEEL_FILE = re.compile(r"[A-Za-z0-9._+-]{1,200}\.whl") +LOCK_PIN = re.compile(r"([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s\\]+)") +LOCK_HASH = re.compile(r"--hash=sha256:([0-9a-f]{64})") +# uv pip check prints "Found N incompatibilities", then one line per problem that starts "The package `NAME`". +CHECK_COUNT = re.compile(r"Found ([0-9]+) incompatibilit(?:y|ies)") +CHECK_NOT_INSTALLED = re.compile(r"The package `[^`]+` requires `([A-Za-z0-9][A-Za-z0-9._-]*)[^`]*`, but it's not installed") + +# The packages in the backend lock that build CPython extension modules, with code that imports each one and code that +# calls it. Other lock packages they need are installed from the lock too; COMPANIONS are lock packages that import a +# target rather than being imported by it (psycopg loads psycopg-binary). +TARGETS = { + "autobahn": ("import autobahn.websocket.utf8validator as module", "assert module.Utf8Validator().validate(b'abc')[0]"), + "cbor2": ("import cbor2; from cbor2 import _cbor2", "assert cbor2.loads(cbor2.dumps({'a': [1, 2]})) == {'a': [1, 2]}"), + "cffi": ("import cffi, _cffi_backend", "assert cffi.FFI().new('int *', 7)[0] == 7"), + "cryptography": ("from cryptography.hazmat.primitives import hashes", "digest = hashes.Hash(hashes.SHA256()); digest.update(b'abc'); assert digest.finalize().hex().startswith('ba7816bf')"), + "greenlet": ("import greenlet", "assert greenlet.greenlet(lambda: 42).switch() == 42"), + "msgpack": ("import msgpack; from msgpack import _cmsgpack", "assert msgpack.unpackb(msgpack.packb({'a': 1})) == {'a': 1}"), + "psycopg-binary": ("import os; os.environ['PSYCOPG_IMPL'] = 'binary'; import psycopg", "assert psycopg.pq.__impl__ == 'binary' and psycopg.pq.version() > 0"), + "pydantic-core": ("from pydantic_core import SchemaValidator, core_schema", "assert SchemaValidator(core_schema.int_schema()).validate_python('1') == 1"), + "ujson": ("import ujson", "assert ujson.loads(ujson.dumps([1, 'a'])) == [1, 'a']"), + "zope-interface": ("import zope.interface._zope_interface_coptimizations; from zope.interface import Interface, implementer", "I = type('I', (Interface,), {}); C = implementer(I)(type('C', (), {})); assert I.providedBy(C())"), +} +COMPANIONS = {"psycopg-binary": ("psycopg",)} + +# Runs in a new process, so that each import starts from the GIL state the interpreter starts with. +IMPORT_PROBE = r""" +import json, sys, warnings +names = json.loads(sys.argv[1]) +gil = (lambda: sys._is_gil_enabled()) if hasattr(sys, "_is_gil_enabled") else (lambda: None) +out = {"gil_before": gil(), "import": "not-run", "call": "not-run"} +scope = {} +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + exec(compile(names["import"], "", "exec"), scope) + out["import"] = "passed" + except BaseException as exc: + out["import"], out["import_error"] = "failed", type(exc).__name__ + out["gil_after_import"] = gil() + if out["import"] == "passed": + try: + exec(compile(names["call"], "", "exec"), scope) + out["call"] = "passed" + except BaseException as exc: + out["call"], out["call_error"] = "failed", type(exc).__name__ + out["gil_after_call"] = gil() +out["warnings"] = [f"{item.category.__name__}: {item.message}" for item in caught][:10] +print(json.dumps(out)) +""" + + +class ProbeError(Exception): + pass + + +def canonical(name): + return re.sub(r"[-_.]+", "-", name).lower() + + +# --------------------------------------------------------------------------- +# The lock and the index + + +def parse_lock(text): + """{name: {"version", "hashes", "lines", "via"}} from a uv pip compile lock with hashes.""" + packages, current = {}, None + for raw in text.splitlines(): + line = raw.strip() + match = LOCK_PIN.match(raw) + if match: + name = canonical(match.group(1)) + if name in packages: + raise ProbeError(f"the lock pins {name} twice") + current = packages[name] = {"version": match.group(2), "hashes": set(), "lines": [raw.rstrip(" \\")], "via": set()} + continue + if current is None or not line: + continue + hashes = LOCK_HASH.findall(line) + if hashes: + current["hashes"].update(hashes) + current["lines"].extend(f" --hash=sha256:{value}" for value in hashes) + elif line.startswith("#"): + via = line.lstrip("#").strip() + via = via[len("via"):].strip() if via.startswith("via") else via + if via and not via.startswith("-r"): + current["via"].add(canonical(via)) + for name, package in packages.items(): + if not NAME.fullmatch(name) or not VERSION.fullmatch(package["version"]) or not package["hashes"]: + raise ProbeError(f"the lock entry for {name} has no hash or an unexpected form") + return packages + + +def requirement_text(lock, names): + """Requirement lines, with hashes, for some of the lock's packages.""" + lines = [] + for name in sorted(names): + entry = lock[name] + pin = entry["lines"][0] + lines.append(" \\\n".join([pin, *entry["lines"][1:]])) + return "\n".join(lines) + "\n" + + +def lock_dependencies(lock, name): + """The lock packages that `name` pulls in, by the lock's "# via" comments, transitively.""" + found, queue = set(), [name] + while queue: + current = queue.pop() + for other, entry in lock.items(): + if current in entry["via"] and other not in found and other != name: + found.add(other) + queue.append(other) + return found + + +_METADATA = {} + + +def fetch_metadata(name, version, directory=None): + """PyPI's JSON for one release, from the API or from DIRECTORY/NAME-VERSION.json, read once per run.""" + key = (name, version, directory) + if key not in _METADATA: + _METADATA[key] = read_metadata(name, version, directory) + return _METADATA[key] + + +def read_metadata(name, version, directory): + if directory is not None: + path = pathlib.Path(directory) / f"{name}-{version}.json" + data = path.read_bytes()[:METADATA_LIMIT + 1] + else: + url = PYPI.format(name=name, version=version) + for attempt in range(FETCH_ATTEMPTS): + try: + request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "claus-ci-probe-python-abi"}) + with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT) as response: + data = response.read(METADATA_LIMIT + 1) + break + except OSError as exc: + if attempt == FETCH_ATTEMPTS - 1: + raise ProbeError(f"cannot read the index metadata of {name} {version} ({type(exc).__name__})") from None + time.sleep(2 ** (attempt + 1)) + if len(data) > METADATA_LIMIT: + raise ProbeError(f"the index metadata of {name} {version} is larger than {METADATA_LIMIT} bytes") + try: + document = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise ProbeError(f"the index metadata of {name} {version} is not JSON") from None + if not isinstance(document, dict) or not isinstance(document.get("urls"), list): + raise ProbeError(f"the index metadata of {name} {version} lists no files") + return document + + +def wheel_files(document, name, version): + """The wheels of one release, each with its file name, SHA-256 and the tags its name expands to.""" + files = [] + for entry in document["urls"]: + filename = entry.get("filename") if isinstance(entry, dict) else None + if not isinstance(filename, str) or not filename.endswith(".whl"): + continue + sha256 = (entry.get("digests") or {}).get("sha256") + if not WHEEL_FILE.fullmatch(filename) or not isinstance(sha256, str) or not SHA256.fullmatch(sha256): + raise ProbeError(f"the index lists a wheel of {name} with an unexpected name or digest") + try: + parsed_name, parsed_version, _, wheel_tags = utils.parse_wheel_filename(filename) + except (utils.InvalidWheelFilename, versions.InvalidVersion): + raise ProbeError(f"the index lists a wheel of {name} whose name cannot be read") from None + if canonical(str(parsed_name)) != name or parsed_version != versions.Version(version): + raise ProbeError(f"the index lists {filename} under {name} {version}") + requires_python = entry.get("requires_python") + files.append({"filename": filename, "sha256": sha256, "tags": sorted(str(tag) for tag in wheel_tags), "yanked": bool(entry.get("yanked")), + "requires_python": requires_python if isinstance(requires_python, str) else None}) + return files + + +# --------------------------------------------------------------------------- +# Tags + + +def interpreter_tags(): + """This interpreter's supported tags, most preferred first, as strings.""" + return [str(tag) for tag in tags.sys_tags()] + + +def admits(requires_python, python_version): + """Whether a wheel's Requires-Python admits this Python version. An unreadable one excludes nothing, as pip treats it.""" + if not requires_python: + return True + try: + return specifiers.SpecifierSet(requires_python).contains(python_version, prereleases=True) + except specifiers.InvalidSpecifier: + return True + + +def compatible(files, supported, lock_hashes, python_version=None): + """Split a release's wheels into those this interpreter accepts whose hash the lock holds, and those it does not hold. + + A wheel is accepted when one of its tags is supported, it is not yanked, and its Requires-Python admits this + version (its release, without a pre-release part, as pip compares it), as installers check. Each list is ordered + by the interpreter's preference, as an installer ranks them. + """ + python_version = python_version or ".".join(str(part) for part in sys.version_info[:3]) + rank = {tag: index for index, tag in enumerate(supported)} + usable = [(min(rank[tag] for tag in item["tags"] if tag in rank), item) for item in files + if not item["yanked"] and admits(item.get("requires_python"), python_version) and any(tag in rank for tag in item["tags"])] + usable.sort(key=lambda pair: (pair[0], pair[1]["filename"])) + in_lock = [item for _, item in usable if item["sha256"] in lock_hashes] + not_in_lock = [item for _, item in usable if item["sha256"] not in lock_hashes] + return in_lock, not_in_lock + + +def abi_categories(files, supported, version=None): + """For this interpreter's version: whether a wheel is published for its platforms with each ABI tag, and whether it accepts that tag. + + The categories are cpXY-cpXY, cpXY-cpXYt, cpXY-abi3, cpXY-abi3t and cpXY-abi3.abi3t, by the Python and ABI parts + of the wheel's file name; an older stable-ABI wheel, such as cp39-abi3, is listed under "older". + """ + version = version or f"cp{sys.version_info[0]}{sys.version_info[1]}" + platforms = {tag.rsplit("-", 1)[1] for tag in supported} + accepted_abis = {tuple(tag.split("-")[:2]) for tag in supported} + names = {f"{version}-{version}": (version, version), f"{version}-{version}t": (version, f"{version}t"), + f"{version}-abi3": (version, "abi3"), f"{version}-abi3t": (version, "abi3t"), f"{version}-abi3.abi3t": (version, "abi3.abi3t")} + out = {} + for key, (python, abi) in names.items(): + published = any(item["filename"][:-4].split("-")[-3:-1] == [python, abi] and any(tag.rsplit("-", 1)[1] in platforms for tag in item["tags"]) for item in files) + accepted = any((python, part) in accepted_abis for part in abi.split(".")) + out[key] = {"published": published, "accepted": accepted} + # Stable-ABI wheels built for an older version, such as cp39-abi3, which a newer interpreter may also accept. + older = sorted({tuple(tag.split("-")[:2]) for item in files for tag in item["tags"] + if tag.rsplit("-", 1)[1] in platforms and tag.split("-")[1] in ("abi3", "abi3t") and tag.split("-")[0] != version}) + out["older"] = [{"tag": f"{python}-{abi}", "published": True, "accepted": (python, abi) in accepted_abis} for python, abi in older] + return out + + +def selected_wheel(installed_tags, files, lock_hashes): + """The release's wheel whose name expands to exactly the tags in the installed WHEEL file, or None. + + Two files with the same tags (a build number) are told apart by the lock; when that is not enough, none is named. + """ + matches = [item for item in files if set(item["tags"]) == set(installed_tags)] + if len(matches) > 1: + matches = [item for item in matches if item["sha256"] in lock_hashes] + return matches[0] if len(matches) == 1 else None + + +# --------------------------------------------------------------------------- +# Outcomes + + +def install_status(exit_status, in_lock, not_in_lock): + """installed, no-wheel, not-in-lock, error (an unexpected failure) or contradiction.""" + if exit_status == 0: + return "installed" if in_lock else "contradiction" + if in_lock: + return "error" + return "not-in-lock" if not_in_lock else "no-wheel" + + +INSTALL_BLOCKED = ("no-wheel", "not-in-lock", "dependency-blocked") +INSTALL_FAILED = ("error", "contradiction", "dependency-failed") + + +def overall(packages): + """(install status, reason, execute status) for the packages report. + + The import and call fail when either raised, when their process did not exit with status 0 (a crash at + shutdown, or a signal, after the result was printed), or when a package enabled the GIL that a free-threaded + build started without (docs/DEPENDENCY-STRATEGY.md: GIL re-enablement is a failed qualification). + """ + blocked = [item["name"] for item in packages if item["install"]["status"] in INSTALL_BLOCKED] + failed = [item["name"] for item in packages if item["install"]["status"] in INSTALL_FAILED] + broken = [item["name"] for item in packages + if item["probe"]["import"] == "failed" or item["probe"]["call"] == "failed" or item["probe"].get("gil_reenabled") is True + or item["probe"].get("exit", {}).get("status") == "failed"] + ran = [item["name"] for item in packages if item["probe"]["call"] == "passed"] + if failed: + install = ("failed", f"unexpected install failures: {', '.join(failed)}") + elif blocked: + install = ("blocked", f"no installable wheel or dependency in the lock for this interpreter: {', '.join(blocked)}") + else: + install = ("passed", "every package installed") + execute = "failed" if broken else "passed" if ran else "not-run" + return install[0], install[1], execute + + +def names_a_missing_wheel(log_text, names): + """Whether the installer's output names one of these packages on a line that says it has no wheel, as uv does when it finds none. + + uv 0.12 prints "Because NAME==VERSION has no usable wheels" or "has no wheels with a matching platform tag", and + "Wheels are required for `NAME`". + """ + lines = [line.lower() for line in log_text.splitlines() if re.search(r"\bno (?:usable |compatible )?wheels?\b|\bwheels are required for\b", line.lower())] + return any(re.search(rf"(? 1: + text = text.replace(path, "") + return re.sub(r"[\x00-\x1f\x7f]", " ", text)[:300] + + +def interpreter_facts(): + config = sysconfig.get_config_var + try: + libc = os.confstr("CS_GNU_LIBC_VERSION") + except (AttributeError, ValueError, OSError): + libc = None + facts = { + "python_vv": scrub(f"Python {sys.version}".replace("\n", " ")), + "version": platform.python_version(), + "implementation": {"name": sys.implementation.name, "cache_tag": sys.implementation.cache_tag, "multiarch": getattr(sys.implementation, "_multiarch", None)}, + "soabi": config("SOABI"), + "py_gil_disabled": config("Py_GIL_DISABLED"), + "gil_enabled": sys._is_gil_enabled() if hasattr(sys, "_is_gil_enabled") else None, + "extension_suffixes": list(importlib.machinery.EXTENSION_SUFFIXES), + "platform": sysconfig.get_platform(), + "system": platform.system(), + "machine": platform.machine(), + "pointer_bits": 64 if sys.maxsize > 2 ** 32 else 32, + "libc": libc, + "macos": platform.mac_ver()[0] or None, + "windows": platform.version() if platform.system() == "Windows" else None, + } + return facts + + +def installed_distribution(name): + importlib.invalidate_caches() + try: + return importlib.metadata.distribution(name) + except importlib.metadata.PackageNotFoundError: + return None + + +def installed_tags(distribution): + text = distribution.read_text("WHEEL") or "" + return sorted({line.split(":", 1)[1].strip() for line in text.splitlines() if line.startswith("Tag:")}) + + +def extension_suffixes(distribution): + """The suffixes of the extension modules a distribution installed, such as .abi3t.so or .cp315-win_amd64.pyd.""" + known = sorted(importlib.machinery.EXTENSION_SUFFIXES, key=len, reverse=True) + found = set() + for item in distribution.files or []: + base = pathlib.PurePosixPath(str(item)).name + if not base.endswith((".so", ".pyd", ".dylib")) or ".dist-info" in str(item): + continue + suffix = next((suffix for suffix in known if base.endswith(suffix)), None) + found.add(suffix if suffix else base[base.index("."):] if "." in base else base) + return sorted(found) + + +def unmet_requirements(lock): + """Requirements of installed distributions that are not met, with the distributions that need them. + + "installed" is None when the requirement is not installed, and the installed version when the requirement excludes it. + """ + environment = markers.default_environment() + installed = {canonical(item.metadata["Name"]): item.version for item in importlib.metadata.distributions() if item.metadata["Name"]} + missing = [] + for distribution in importlib.metadata.distributions(): + name = canonical(distribution.metadata["Name"] or "") + for text in distribution.requires or []: + requirement = requirements.Requirement(text) + if requirement.marker is not None and not requirement.marker.evaluate({**environment, "extra": ""}): + continue + needed = canonical(requirement.name) + if needed not in installed: + missing.append({"requirement": needed, "required_by": name, "in_lock": needed in lock, "installed": None}) + continue + try: + met = requirement.specifier.contains(installed[needed], prereleases=True) + except versions.InvalidVersion: + met = False + if not met: + missing.append({"requirement": needed, "required_by": name, "in_lock": needed in lock, "installed": installed[needed]}) + return sorted(missing, key=lambda item: (item["requirement"], item["required_by"])) + + +# --------------------------------------------------------------------------- +# Commands + + +def wheels_report(lock, metadata_dir=None): + supported = interpreter_tags() + packages, uncovered, late = [], [], [] + for name in sorted(lock): + entry = lock[name] + files = wheel_files(fetch_metadata(name, entry["version"], metadata_dir), name, entry["version"]) + in_lock, not_in_lock = compatible(files, supported, entry["hashes"]) + packages.append({ + "name": name, "version": entry["version"], + "compatible_in_lock": [item["filename"] for item in in_lock], + "compatible_not_in_lock": [item["filename"] for item in not_in_lock], + }) + if not in_lock: + uncovered.append(name) + if not_in_lock: + late.append(name) + return {"packages": packages, "uncovered": uncovered, "published_not_in_lock": late} + + +def run_uv(uv, lock, names): + """Install some lock packages with uv into this environment, binary only and hash-checked; return the exit status.""" + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "requirements.txt" + path.write_text(requirement_text(lock, names), encoding="utf-8") + command = [uv, "pip", "install", "--no-config", "--python", sys.executable, "--default-index", "https://pypi.org/simple", + "--require-hashes", "--only-binary", ":all:", "--no-deps", "-r", str(path)] + print(f"$ uv pip install --require-hashes --only-binary :all: --no-deps {' '.join(sorted(names))}", flush=True) + try: + process = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=UV_TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + print(f" uv could not be run ({type(exc).__name__})", flush=True) + return None + print(process.stdout.decode("utf-8", "replace")[-4000:], flush=True) + return process.returncode + + +def exit_status(returncode): + """How a probe process ended: passed only with status 0. A negative status is the POSIX signal that ended it; + a Windows crash is a large positive status, such as 3221225477 (0xC0000005).""" + if returncode == 0: + return {"status": "passed", "returncode": 0} + out = {"status": "failed", "returncode": returncode} + if returncode is not None and returncode < 0: + try: + out["signal"] = signal.Signals(-returncode).name + except ValueError: + out["signal"] = str(-returncode) + return out + + +def import_probe(target): + code_import, code_call = TARGETS[target] + command = [sys.executable, "-I", "-c", IMPORT_PROBE, json.dumps({"import": code_import, "call": code_call})] + try: + process = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, timeout=PROBE_TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + return {"import": "failed", "call": "not-run", "import_error": type(exc).__name__, "warnings": [], "exit": exit_status(None)} + # The result the process printed is kept as it is, and the exit is recorded beside it: a process that printed a + # passing import and call and then crashed, exited nonzero or was killed by a signal still fails (overall). + ended = exit_status(process.returncode) + try: + out = json.loads(process.stdout.decode("utf-8").strip().splitlines()[-1]) + if not isinstance(out, dict): + raise ValueError("the last line is not a result") + except (UnicodeDecodeError, ValueError, IndexError): + return {"import": "failed", "call": "not-run", "import_error": "no result", "warnings": [], "exit": ended} + out["exit"] = ended + out["warnings"] = [scrub(text) for text in out.get("warnings", [])] + before, after = out.get("gil_before"), out.get("gil_after_call") + out["gil_reenabled"] = before is False and after is True + return out + + +def probe_package(target, lock, uv, supported, metadata_dir=None): + entry = lock[target] + files = wheel_files(fetch_metadata(target, entry["version"], metadata_dir), target, entry["version"]) + in_lock, not_in_lock = compatible(files, supported, entry["hashes"]) + item = { + "name": target, "version": entry["version"], + "categories": abi_categories(files, supported), + "compatible_in_lock": [wheel["filename"] for wheel in in_lock], + "compatible_not_in_lock": [wheel["filename"] for wheel in not_in_lock], + "thread_safety": {"status": "not-tested", "reason": "this probe imports and calls each package once, from one thread"}, + "probe": {"import": "not-run", "call": "not-run"}, + } + status = install_status(run_uv(uv, lock, [target]), [wheel["filename"] for wheel in in_lock], [wheel["filename"] for wheel in not_in_lock]) + item["install"] = {"status": status} + if status != "installed": + return item + distribution = installed_distribution(target) + wheel_tags = installed_tags(distribution) if distribution else [] + chosen = selected_wheel(wheel_tags, files, entry["hashes"]) + item["selected"] = { + "filename": chosen["filename"] if chosen else None, + "sha256": chosen["sha256"] if chosen else None, + "in_lock": bool(chosen and chosen["sha256"] in entry["hashes"]), + "tags": wheel_tags, + "preferred": bool(chosen and in_lock and chosen["filename"] == in_lock[0]["filename"]), + } + item["extension_suffixes"] = extension_suffixes(distribution) if distribution else [] + if chosen is None or not item["selected"]["in_lock"]: + item["install"]["status"] = "contradiction" + return item + # The lock packages the target needs, and those that load it, installed the same way. + others = set(COMPANIONS.get(target, ())) + for name in list(others): + others |= lock_dependencies(lock, name) + others |= lock_dependencies(lock, target) + others = {name for name in others if installed_distribution(name) is None} + if others: + dependency_status = run_uv(uv, lock, others) + if dependency_status != 0: + # Find out whether a dependency lacks a wheel here, or failed for another reason. + reports = {name: compatible(wheel_files(fetch_metadata(name, lock[name]["version"], metadata_dir), name, lock[name]["version"]), supported, lock[name]["hashes"]) for name in others} + lacking = sorted(name for name, (found, _) in reports.items() if not found) + item["install"] = {"status": "dependency-blocked" if lacking else "dependency-failed", "dependencies": lacking or sorted(others)} + return item + unmet = [entry for entry in unmet_requirements(lock) if entry["required_by"] in ({target} | others | set(COMPANIONS.get(target, ())))] + if unmet: + blocked = all(not entry["in_lock"] and entry.get("installed") is None for entry in unmet) + item["install"] = {"status": "dependency-blocked" if blocked else "dependency-failed", "dependencies": sorted({entry["requirement"] for entry in unmet})} + return item + item["probe"] = import_probe(target) + return item + + +def packages_report(lock, uv, metadata_dir=None, targets=None): + supported = interpreter_tags() + names = targets or sorted(TARGETS) + for name in names: + if name not in TARGETS or name not in lock: + raise ProbeError(f"{name} is not a target the lock pins") + packages = [probe_package(name, lock, uv, supported, metadata_dir) for name in names] + install, reason, execute = overall(packages) + return {"packages": packages, "outcome": {"install": install, "install_reason": reason, "execute": execute}} + + +def summary_lines(report): + lines = [f"### Python ABI probe: {report['interpreter']['python_vv'][:100]}", ""] + facts = report["interpreter"] + lines += [f"SOABI `{facts['soabi']}`, Py_GIL_DISABLED `{facts['py_gil_disabled']}`, GIL enabled `{facts['gil_enabled']}`, platform `{facts['platform']}`, extension suffixes `{' '.join(facts['extension_suffixes'])}`", ""] + if "wheels" in report: + uncovered = report["wheels"]["uncovered"] + lines += [f"Lock packages without a wheel this interpreter can install: {', '.join(uncovered) or 'none'}", ""] + if "requirements" in report: + missing = report["requirements"]["missing"] + lines += [f"Unmet requirements of installed packages: {', '.join(sorted({item['requirement'] for item in missing})) or 'none'}", ""] + if "packages" in report: + lines += ["| Package | Install | Selected wheel | Extension suffixes | GIL before / after | Import | Call | Exit |", "|---|---|---|---|---|---|---|---|"] + for item in report["packages"]["packages"]: + selected = item.get("selected", {}).get("filename") or "none" + probe = item["probe"] + gil = f"{probe.get('gil_before')} / {probe.get('gil_after_call')}" if probe["import"] != "not-run" else "-" + ended = probe.get("exit") + exit_text = "-" if ended is None else ended.get("signal") or ("none" if ended["returncode"] is None else str(ended["returncode"])) + lines.append(f"| {item['name']} {item['version']} | {item['install']['status']} | {selected} | {' '.join(item.get('extension_suffixes', [])) or '-'} | {gil} | {probe['import']} | {probe['call']} | {exit_text} |") + outcome = report["packages"]["outcome"] + lines += ["", f"Install: {outcome['install']} ({outcome['install_reason']}); import and call: {outcome['execute']}. Thread safety was not tested.", ""] + return lines + + +def write_output(path, pairs): + if path is None: + return + with open(path, "a", encoding="utf-8") as handle: + for key, value in pairs: + if "\n" in value or "\r" in value: + raise ProbeError(f"the output {key} spans lines") + handle.write(f"{key}={value}\n") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + parser.add_argument("command", choices=("wheels", "requirements", "packages", "install-stage", "pin")) + parser.add_argument("--lock", type=pathlib.Path) + parser.add_argument("--uv", help="the uv executable, for packages") + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--summary", type=pathlib.Path) + parser.add_argument("--github-output", type=pathlib.Path) + parser.add_argument("--metadata-dir", type=pathlib.Path, help="read NAME-VERSION.json files here instead of PyPI") + parser.add_argument("--target", action="append", help="probe only these packages (packages)") + parser.add_argument("--sync", help="uv pip sync's exit status (install-stage)") + parser.add_argument("--check", help="uv pip check's exit status, or skip (install-stage)") + parser.add_argument("--log", type=pathlib.Path, help="the installer's output (install-stage)") + parser.add_argument("--wheels", type=pathlib.Path, help="a wheels report (install-stage)") + parser.add_argument("--requirements", type=pathlib.Path, help="a requirements report (install-stage)") + parser.add_argument("--name", action="append", help="a lock package to pin (pin)") + args = parser.parse_args(argv) + + if args.command == "install-stage": + if args.sync is None or args.check is None or args.log is None or args.wheels is None: + parser.error("install-stage needs --sync, --check, --log and --wheels") + if not re.fullmatch(r"[0-9]{1,3}", args.sync) or not re.fullmatch(r"[0-9]{1,3}|skip", args.check): + parser.error("--sync takes an exit status, and --check an exit status or skip") + try: + wheels = json.loads(args.wheels.read_text(encoding="utf-8"))["wheels"] if args.wheels.exists() else None + requirements_report = json.loads(args.requirements.read_text(encoding="utf-8"))["requirements"] if args.requirements and args.requirements.exists() else None + log_text = args.log.read_text(encoding="utf-8", errors="replace") if args.log.exists() else "" + status, reason = install_stage(int(args.sync), None if args.check == "skip" else int(args.check), log_text, wheels, requirements_report) + write_output(args.github_output, [("status", status), ("reason", reason)]) + except (OSError, KeyError, TypeError, ValueError, ProbeError) as exc: + print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + print(f"install: {status} ({reason})") + return 1 if status == "failed" else 0 + + if args.command == "pin": + if args.lock is None or args.output is None or not args.name: + parser.error("pin needs --lock, --name and --output") + try: + lock = parse_lock(args.lock.read_bytes().decode("utf-8")) + names = {canonical(name) for name in args.name} + missing = sorted(names - set(lock)) + if missing: + raise ProbeError(f"the lock does not pin {', '.join(missing)}") + args.output.write_text(requirement_text(lock, names), encoding="utf-8") + except (OSError, UnicodeDecodeError, ProbeError) as exc: + print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + return 0 + + if args.lock is None or args.output is None: + parser.error(f"{args.command} needs --lock and --output") + if tags is None: + print("error: packaging is not installed in this environment; install it from the lock first", file=sys.stderr) + return 2 + try: + lock_bytes = args.lock.read_bytes() + lock = parse_lock(lock_bytes.decode("utf-8")) + report = {"schema": SCHEMA, "interpreter": interpreter_facts(), + "lock": {"path": args.lock.as_posix() if not args.lock.is_absolute() else args.lock.name, "sha256": hashlib.sha256(lock_bytes).hexdigest()}} + failed = False + if args.command == "wheels": + report["wheels"] = wheels_report(lock, args.metadata_dir) + pairs = [("uncovered", ",".join(report["wheels"]["uncovered"]))] + elif args.command == "requirements": + report["requirements"] = {"missing": unmet_requirements(lock)} + pairs = [("missing", ",".join(sorted({item["requirement"] for item in report["requirements"]["missing"]})))] + else: + if not args.uv: + parser.error("packages needs --uv") + report["wheels"] = wheels_report(lock, args.metadata_dir) + report["packages"] = packages_report(lock, args.uv, args.metadata_dir, args.target) + outcome = report["packages"]["outcome"] + failed = outcome["install"] == "failed" or outcome["execute"] == "failed" + pairs = [("install", outcome["install"]), ("install-reason", outcome["install_reason"]), ("execute", outcome["execute"])] + text = json.dumps(report, indent=2, ensure_ascii=True, sort_keys=False) + "\n" + args.output.write_text(text, encoding="utf-8") + write_output(args.github_output, pairs) + if args.summary: + with open(args.summary, "a", encoding="utf-8") as handle: + handle.write("\n".join(summary_lines(report)) + "\n") + except (OSError, UnicodeDecodeError, ValueError, ProbeError) as exc: + print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + for line in summary_lines(report): + print(line) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/tests/test_environment_probe.py b/scripts/ci/tests/test_environment_probe.py index e2658ca..1350100 100644 --- a/scripts/ci/tests/test_environment_probe.py +++ b/scripts/ci/tests/test_environment_probe.py @@ -133,6 +133,38 @@ def interpreter_patches(system, interpreter): return patches +class FakeNative: + """Answers NativeQueries' calls from a table, as one macOS or Windows machine would; anything missing gives None.""" + + def __init__(self, sysctl=None, machine=None, token=None, bios=None): + self.values, self.machine, self.token, self.bios = dict(sysctl or {}), machine, token, bios + + def sysctl(self, name, integer): + value = self.values.get(name) + if value is not None and isinstance(value, int) != integer: + raise AssertionError(f"{name} is read as the wrong type") + return value + + def windows_native_machine(self): + return self.machine + + def windows_token(self): + return self.token + + def windows_bios(self): + return self.bios + + +# A GitHub-hosted macos-15 runner: an Apple Virtualization guest on Apple silicon. +MACOS_ARM = {"hw.optional.arm64": 1, "kern.hv_vmm_present": 1, "hw.model": "VirtualMac2,1", "kern.osproductversion": "15.7.1", + "kern.osrelease": "24.6.0", "sysctl.proc_translated": 0} +# A GitHub-hosted macos-15-intel runner: Intel Macs have no hw.optional.arm64 set and no sysctl.proc_translated. +MACOS_INTEL = {"hw.optional.arm64": 0, "hw.optional.x86_64": 1, "kern.hv_vmm_present": 1, "hw.model": "VMware7,1", + "kern.osproductversion": "15.7.1", "kern.osrelease": "24.6.0"} +HYPER_V_BIOS = {"SystemManufacturer": "Microsoft Corporation", "SystemProductName": "Virtual Machine"} +NO_ADMIN = {"elevated": False, "administrators": False, "local_system": False} + + @contextlib.contextmanager def without(module, name): """Remove an attribute, such as os.getresuid on macOS, for the duration of a test.""" @@ -151,7 +183,8 @@ class FakeMachine: build targets, which emulation and personality changes do not alter. """ - def __init__(self, files=None, remove=(), env=None, machine="x86_64", release="6.17.0-1022-azure", system="Linux", interpreter="x86_64"): + def __init__(self, files=None, remove=(), env=None, machine="x86_64", release="6.17.0-1022-azure", system="Linux", interpreter="x86_64", native=None): + self.native = native or FakeNative() self.directory = tempfile.TemporaryDirectory() self.root = pathlib.Path(self.directory.name) / "root" content = dict(RUNNER_FILES if files is None else files) @@ -188,6 +221,7 @@ def __exit__(self, *exc): def probe(self, **kwargs): kwargs.setdefault("probe_node", False) + kwargs.setdefault("native", self.native) return FakeProbe(self.root, env=self.env, git_dir=self.git, system=self.system, **kwargs) def result(self, **kwargs): @@ -804,27 +838,160 @@ def test_a_probe_failure_is_kept_apart_from_the_other_values(self): class OtherSystemTests(Case): - # Off Linux the collector does not measure the layer kind, elevation or capabilities, so these rules fail. - UNMEASURED = ["policy-app-non-root", "policy-minimal-capabilities", "policy-minimal-devices", "policy-no-privileged-container"] - - def test_a_system_other_than_linux(self): - with FakeMachine(system="Windows", interpreter="aarch64") as machine: - result = machine.result() + def windows(self, interpreter="aarch64", machine=0xAA64, token=NO_ADMIN, bios=HYPER_V_BIOS, version="10.0.26100"): + native = FakeNative(machine=machine, token=token, bios=bios) + with mock.patch.object(collector.platform, "version", return_value=version), \ + FakeMachine(system="Windows", interpreter=interpreter, native=native) as fake: + return fake.result() + + def macos(self, values, interpreter="aarch64", uid=501): + with mock.patch.object(collector.os, "geteuid", return_value=uid), mock.patch.object(collector.os, "getuid", return_value=uid), \ + FakeMachine(system="Darwin", interpreter=interpreter, native=FakeNative(sysctl=values)) as fake: + return fake.result() + + def policy_codes(self, result): + return [code for code in validator.validate_document(result, SCHEMA).codes() if code.startswith("policy-")] + + def test_windows_on_arm(self): + result = self.windows() layers = result["environment"]["layers"] - self.assertEqual(len(layers), 1) - self.assertEqual(value(layers[0]["os"]["family"]), "windows") - self.assertEqual(layers[0]["kind"]["provenance"], "unknown") - self.assertEqual(result["workload"]["isolation"]["seccomp"]["provenance"], "unknown") - identity = result["workload"]["identity"] - self.assertEqual((identity["superuser"]["provenance"], identity["real_uid"]["provenance"], identity["saved_uid"]["provenance"]), ("unknown", "not-applicable", "not-applicable")) - self.assertEqual(result["probe_errors"], []) + self.assertEqual(kinds(result), ["host", "vm"]) + self.assertEqual(layers[1]["technology"], {"value": "hyper-v", "provenance": "inferred", "evidence": [collector.WINDOWS_BIOS_EVIDENCE + "#SystemProductName"]}) + self.assertFact(layers[1]["os"]["family"], "windows", "observed") + self.assertFact(layers[1]["os"]["version"], "10.0.26100", "observed") + self.assertFact(layers[1]["os"]["kernel"], "10.0.26100", "observed") + self.assertEqual(layers[1]["cpu"]["arch"], {"value": "aarch64", "provenance": "observed", "evidence": ["python:IsWow64Process2#nativeMachine"]}) + self.assertEqual(layers[1]["cpu"]["execution"]["provenance"], "unknown") python = result["workload"]["runtimes"]["python"] self.assertFact(python["arch"], "aarch64", "observed") + self.assertEqual(python["execution"], {"value": "native", "provenance": "inferred", "evidence": ["python:sysconfig.get_platform", "python:IsWow64Process2#nativeMachine"]}) self.assertEqual(python["libc"]["provenance"], "unknown") + isolation = result["workload"]["isolation"] + self.assertEqual({provenance for _, provenance in walk_provenance({k: v for k, v in isolation.items() if k != "capabilities"})}, {"not-applicable"}) + # The token's privileges, which stand in for capabilities, are not read. + self.assertEqual({fact["provenance"] for fact in isolation["capabilities"].values()}, {"unknown"}) + identity = result["workload"]["identity"] + self.assertFact(identity["superuser"], False, "observed") + self.assertEqual({identity[name]["provenance"] for name in ("uid", "real_uid", "saved_uid", "gid")}, {"not-applicable"}) + self.assertEqual(result["probe_errors"], []) + self.assertValidRecord(result, policy=False) + # A token without administrator rights, in a VM the record shows, still fails minimal-capabilities, which + # nothing on Windows measures. + self.assertEqual(self.policy_codes(result), ["policy-minimal-capabilities"]) + + def test_an_x64_process_emulated_on_windows_on_arm(self): + result = self.windows(interpreter="x86_64") + python = result["workload"]["runtimes"]["python"] + self.assertFact(python["arch"], "x86_64", "observed") + self.assertEqual(python["execution"], {"value": "emulated", "provenance": "inferred", "evidence": ["python:sysconfig.get_platform", "python:IsWow64Process2#nativeMachine"]}) + # IsWow64Process2 names the Arm machine to an emulated x64 process too. + self.assertFact(result["environment"]["layers"][1]["cpu"]["arch"], "aarch64", "observed") + self.assertValidRecord(result, policy=False) + self.assertFact(self.windows(interpreter="x86_64", machine=0x8664)["workload"]["runtimes"]["python"]["execution"], "native", "inferred") + # Node runs in its own process, compared with the same machine. + with FakeMachine(system="Windows", native=FakeNative(machine=0xAA64)) as fake: + probe = fake.probe() + self.assertFact(probe.execution("x86_64", "cmd:node#process.arch", None), "emulated", "inferred") + self.assertFact(probe.execution("aarch64", "cmd:node#process.arch", None), "native", "inferred") + # Without IsWow64Process2 nothing is compared, and the machine is not named. + result = self.windows(machine=None) + self.assertEqual(result["workload"]["runtimes"]["python"]["execution"]["provenance"], "unknown") + self.assertEqual(result["environment"]["layers"][-1]["cpu"]["arch"]["provenance"], "unknown") + self.assertValidRecord(result, policy=False) + + def test_an_administrator_token_is_the_superuser(self): + elevated, administrators, system = ("python:GetTokenInformation(TokenElevation)", "python:CheckTokenMembership(BUILTIN\\Administrators)", + "python:GetTokenInformation(TokenUser)") + for token, evidence in ( + ({"elevated": True, "administrators": True, "local_system": False}, [elevated, administrators]), + # With UAC off, an administrator's token holds the enabled group without being marked elevated. + ({"elevated": False, "administrators": True, "local_system": False}, [administrators]), + ({"elevated": None, "administrators": None, "local_system": True}, [system]), + ): + with self.subTest(token=token): + result = self.windows(token=token) + self.assertEqual(result["workload"]["identity"]["superuser"], {"value": True, "provenance": "observed", "evidence": evidence}) + self.assertValidRecord(result, policy=False) + self.assertEqual(self.policy_codes(result), ["policy-app-non-root", "policy-minimal-capabilities"]) + # A token that cannot be read, or is read in part, does not show a non-root user. + for token in (None, {"elevated": False, "administrators": None, "local_system": False}): + with self.subTest(token=token): + result = self.windows(token=token) + self.assertEqual(result["workload"]["identity"]["superuser"]["provenance"], "unknown") + self.assertEqual(self.policy_codes(result), ["policy-app-non-root", "policy-minimal-capabilities"]) + + def test_the_bios_values_name_the_hypervisor(self): + for bios, technology, evidence in ( + ({"SystemManufacturer": "QEMU", "SystemProductName": "Standard PC (Q35 + ICH9, 2009)"}, "qemu", "SystemManufacturer"), + ({"SystemManufacturer": "Amazon EC2", "SystemProductName": "m7g.large"}, "amazon", "SystemManufacturer"), + ({"SystemManufacturer": "Dell Inc.", "SystemProductName": "VMware20,1"}, "vmware", "SystemProductName"), + ): + with self.subTest(bios=bios): + result = self.windows(bios=bios) + layer = result["environment"]["layers"][-1] + self.assertEqual(layer["technology"], {"value": technology, "provenance": "inferred", "evidence": [f"{collector.WINDOWS_BIOS_EVIDENCE}#{evidence}"]}) + self.assertValidRecord(result, policy=False) + # Values that name no hypervisor do not show bare metal, so no layer kind is recorded and the container rules cannot pass. + for bios, reason in (({"SystemManufacturer": "Dell Inc.", "SystemProductName": "OptiPlex 7090"}, "does not show bare metal"), (None, "cannot be read")): + with self.subTest(bios=bios): + result = self.windows(bios=bios) + self.assertEqual(kinds(result), [None]) + self.assertIn(reason, result["environment"]["layers"][0]["kind"]["reason"]) + self.assertValidRecord(result, policy=False) + self.assertEqual(self.policy_codes(result), ["policy-minimal-capabilities", "policy-minimal-devices", "policy-no-privileged-container"]) + result = self.windows(version="10.0.26100; x") + self.assertEqual(result["environment"]["layers"][-1]["os"]["version"]["provenance"], "unknown") + + def test_macos_on_apple_silicon(self): + result = self.macos(MACOS_ARM) + layers = result["environment"]["layers"] + self.assertEqual(kinds(result), ["host", "vm"]) + self.assertEqual(layers[1]["kind"]["evidence"], ["python:sysctlbyname(kern.hv_vmm_present)"]) + self.assertEqual(layers[1]["technology"], {"value": "apple-virtualization", "provenance": "inferred", "evidence": ["python:sysctlbyname(hw.model)"]}) + self.assertFact(layers[1]["os"]["family"], "macos", "observed") + self.assertFact(layers[1]["os"]["version"], "15.7.1", "observed") + self.assertFact(layers[1]["os"]["kernel"], "24.6.0", "observed") + self.assertEqual(layers[1]["cpu"]["arch"], {"value": "aarch64", "provenance": "observed", "evidence": ["python:sysctlbyname(hw.optional.arm64)"]}) + python = result["workload"]["runtimes"]["python"] + self.assertEqual(python["execution"], {"value": "native", "provenance": "inferred", "evidence": ["python:sysconfig.get_platform", "python:sysctlbyname(sysctl.proc_translated)"]}) + self.assertEqual({provenance for _, provenance in walk_provenance(result["workload"]["isolation"])}, {"not-applicable"}) + self.assertEqual(result["probe_errors"], []) + self.assertValidRecord(result, policy=False) + self.assertEqual(self.policy_codes(result), []) + # A root process is the superuser on macOS as anywhere else. + self.assertEqual(self.policy_codes(self.macos(MACOS_ARM, uid=0)), ["policy-app-non-root"]) + + def test_a_process_rosetta_translates(self): + result = self.macos({**MACOS_ARM, "sysctl.proc_translated": 1}, interpreter="x86_64") + python = result["workload"]["runtimes"]["python"] + self.assertFact(python["arch"], "x86_64", "observed") + self.assertEqual(python["execution"], {"value": "emulated", "provenance": "inferred", "evidence": ["python:sysconfig.get_platform", "python:sysctlbyname(sysctl.proc_translated)"]}) + # hw.optional.arm64 is 1 under Rosetta too, so the machine is still named. + self.assertFact(result["environment"]["layers"][1]["cpu"]["arch"], "aarch64", "observed") + self.assertValidRecord(result, policy=False) + + def test_macos_on_intel(self): + result = self.macos(MACOS_INTEL, interpreter="x86_64") + layers = result["environment"]["layers"] + self.assertFact(layers[1]["technology"], "vmware", "inferred") + self.assertEqual(layers[1]["cpu"]["arch"], {"value": "x86_64", "provenance": "observed", "evidence": ["python:sysctlbyname(hw.optional.x86_64)"]}) + # Intel Macs have no sysctl.proc_translated, so the build is compared with the machine. + self.assertEqual(result["workload"]["runtimes"]["python"]["execution"], {"value": "native", "provenance": "inferred", "evidence": ["python:sysconfig.get_platform", "python:sysctlbyname(hw.optional.x86_64)"]}) + self.assertValidRecord(result, policy=False) + self.assertEqual(self.policy_codes(result), []) + + def test_macos_without_usable_values(self): + result = self.macos({**MACOS_ARM, "kern.hv_vmm_present": 0}) + self.assertEqual(kinds(result), [None]) + self.assertIn("does not show bare metal", result["environment"]["layers"][0]["kind"]["reason"]) + self.assertValidRecord(result, policy=False) + self.assertEqual(self.policy_codes(result), ["policy-minimal-devices", "policy-no-privileged-container"]) + result = self.macos({"kern.hv_vmm_present": 1, "hw.model": "Mac14,2", "kern.osproductversion": "15.7.1; x", "kern.osrelease": "24.6.0\n"}) + layer = result["environment"]["layers"][-1] + self.assertEqual((layer["technology"]["provenance"], layer["os"]["version"]["provenance"], layer["os"]["kernel"]["provenance"], layer["cpu"]["arch"]["provenance"]), + ("unknown", "unknown", "unknown", "unknown")) + self.assertEqual(result["workload"]["runtimes"]["python"]["execution"]["provenance"], "unknown") self.assertValidRecord(result, policy=False) - findings = validator.validate_document(result, SCHEMA) - self.assertEqual(sorted({code for code in findings.codes() if code[len("policy-"):] in validator.RULE_SCOPE}), self.UNMEASURED) - self.assertIn("policy-engine-rootless", findings.codes("warning")) def test_user_ids_off_linux(self): with FakeMachine(system="FreeBSD") as machine: @@ -849,7 +1016,9 @@ def test_user_ids_off_linux(self): self.assertValidRecord(result, policy=False) codes = validator.validate_document(result, SCHEMA).codes() self.assertNotIn("policy-app-non-root", codes) - self.assertIn("policy-minimal-capabilities", codes) + # Linux capabilities do not exist on macOS; with no hypervisor signal, no container is ruled out. + self.assertNotIn("policy-minimal-capabilities", codes) + self.assertIn("policy-no-privileged-container", codes) class RunEvidenceTests(Case): @@ -1437,6 +1606,38 @@ def test_stage_options(self): self.assertEqual(result["stages"]["initialize"]["status"], "not-run") self.assertEqual(result["stages"]["performance"]["status"], "not-applicable") + def test_extensions(self): + with tempfile.TemporaryDirectory() as directory: + directory = pathlib.Path(directory) + output = directory / "result.json" + good = directory / "abi.json" + good.write_text(json.dumps({"schema": "claus-python-abi/1", "packages": []}), encoding="utf-8") + code, _ = self.run_main("--output", output, "--no-node", "--extension", f"x-python-abi={good}") + self.assertIn(code, (0, 1)) + result = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(result["extensions"], {"x-python-abi": {"schema": "claus-python-abi/1", "packages": []}}) + self.assertValidRecord(result, policy=False) + output.unlink() + (directory / "list.json").write_text("[]", encoding="utf-8") + (directory / "bad.json").write_bytes(b"\xff") + (directory / "big.json").write_text('{"a": "' + "x" * collector.EXTENSION_LIMIT + '"}', encoding="utf-8") + (directory / "token.json").write_text(json.dumps({"t": "ghp_" + "A" * 36}), encoding="utf-8") + for value, message in ((f"python-abi={good}", "x-NAME=PATH"), (f"x-Python={good}", "x-NAME=PATH"), ("x-abi=", "x-NAME=PATH"), + (f"x-abi={directory / 'missing.json'}", "cannot read the file"), (f"x-abi={directory / 'list.json'}", "not a JSON object"), + (f"x-abi={directory / 'bad.json'}", "not a UTF-8 JSON document"), (f"x-abi={directory / 'big.json'}", "larger than"), + (f"x-abi={directory}", "cannot read the file")): + with self.subTest(value=value): + code, text = self.run_main("--output", output, "--no-node", "--extension", value) + self.assertEqual(code, 2) + self.assertIn(message, text) + self.assertFalse(output.exists()) + code, text = self.run_main("--output", output, "--no-node", "--extension", f"x-a={good}", "--extension", f"x-a={good}") + self.assertIn("given twice", text) + code, text = self.run_main("--output", output, "--no-node", "--extension", f"x-a={directory / 'token.json'}") + self.assertEqual(code, 2) + self.assertIn("looks like a GitHub token", text) + self.assertFalse(output.exists()) + def test_a_record_that_looks_like_it_holds_a_secret_is_not_written(self): with tempfile.TemporaryDirectory() as directory: output = pathlib.Path(directory) / "result.json" @@ -1587,6 +1788,130 @@ def test_the_summary_escapes_table_cells(self): self.assertIsNone(re.search(r"(?> "{calls}"\n' + f"case \"$1\" in info) printf '%s' '{json.dumps(self.INFO)}' ;; container) printf '%s' '{json.dumps(self.inspect())}' ;; *) exit 9 ;; esac\n", + encoding="utf-8", + ) + podman.chmod(0o755) + run = lambda *argv: CommandTests.run_main(self, *argv) # noqa: E731 + with mock.patch.dict(os.environ, {"PATH": str(directory)}): + for argv, message in ((["--amend-container", path], "needs --podman-container"), + (["--amend-container", path, "--podman-container", "bad name"], "needs --podman-container"), + (["--amend-container", path, "--podman-container", "claus-ubi", "--no-node"], "takes --podman-container only"), + (["--output", path, "--podman-container", "claus-ubi"], "goes with --amend-container")): + with self.subTest(argv=argv): + code, text = run(*argv) + self.assertEqual(code, 2) + self.assertIn(message, text) + self.assertFalse(calls.exists()) + code, text = run("--amend-container", path, "--podman-container", "claus-ubi") + self.assertEqual(code, 0, text) + self.assertIn("engine rootless (observed)", text) + self.assertEqual(calls.read_text(encoding="utf-8").splitlines(), ["info --format json", "container inspect --format json claus-ubi"]) + self.assertValidRecord(json.loads(path.read_text(encoding="utf-8"))) + # A failure leaves the record as it was. + before = path.read_bytes() + with mock.patch.dict(os.environ, {"PATH": str(directory)}): + code, text = run("--amend-container", path, "--podman-container", "other") + self.assertEqual((code, path.read_bytes()), (2, before)) + self.assertIn("another container than other; the record was not changed", text) + with mock.patch.dict(os.environ, {"PATH": str(directory / "missing")}): + code, text = run("--amend-container", path, "--podman-container", "claus-ubi") + self.assertEqual(code, 2) + self.assertIn("podman is not on PATH", text) + + class ThisMachineTests(Case): SENTINELS = { "GITHUB_TOKEN": "ghs_" + "Q" * 36, @@ -1658,7 +1983,7 @@ def test_platform_tags(self): def test_layer_technologies(self): # Every technology the collector can write is on the kind of layer the validator binds it to. - for technology in {name for _, name in collector.DMI_VENDORS} | {"hyper-v"}: + for technology in {name for _, name in collector.DMI_VENDORS + collector.MACOS_MODELS} | {"hyper-v"}: self.assertEqual(validator.TECHNOLOGY_KINDS.get(technology), "vm", technology) for technology in {name for name in collector.CONTAINER_TECHNOLOGIES.values() if name} | {"docker", "podman", "apptainer"}: self.assertEqual(validator.TECHNOLOGY_KINDS.get(technology), "container", technology) @@ -1696,7 +2021,14 @@ def test_no_forbidden_modules(self): elif isinstance(node, ast.ImportFrom): imported.add((node.module or "").split(".")[0]) allowed = {"argparse", "datetime", "json", "os", "pathlib", "platform", "re", "shutil", "signal", "stat", "struct", "subprocess", "sys", "sysconfig", "threading", "time"} - self.assertEqual(imported - allowed, set()) + # ctypes and winreg are imported inside NativeQueries only, for the macOS and Windows reads. + native = next(node for node in self.tree.body if isinstance(node, ast.ClassDef) and node.name == "NativeQueries") + local = {alias.name.split(".")[0] for node in ast.walk(native) if isinstance(node, (ast.Import, ast.ImportFrom)) + for alias in (node.names if isinstance(node, ast.Import) else [types.SimpleNamespace(name=node.module or "")])} + self.assertEqual(local, {"ctypes", "winreg"}) + self.assertEqual(imported - allowed - local, set()) + top = {alias.name.split(".")[0] for node in self.tree.body if isinstance(node, ast.Import) for alias in node.names} + self.assertEqual(top & local, set()) def test_no_forbidden_calls(self): forbidden = {"platform.node", "platform.uname", "os.uname", "os.getlogin", "os.environ.items", "os.environ.keys", @@ -1706,16 +2038,25 @@ def test_no_forbidden_calls(self): environ = [node for node, name in self.names() if name == "os.environ"] self.assertEqual(len(environ), 1, "os.environ is read only through Probe.env") - def test_one_subprocess_without_a_shell(self): + def test_two_subprocesses_without_a_shell(self): calls = [node for node in ast.walk(self.tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess"] - self.assertEqual([call.func.attr for call in calls], ["Popen"]) - keywords = {keyword.arg: keyword.value for keyword in calls[0].keywords} + self.assertEqual(sorted(call.func.attr for call in calls), ["Popen", "run"]) + popen = next(call for call in calls if call.func.attr == "Popen") + keywords = {keyword.arg: keyword.value for keyword in popen.keywords} self.assertNotIn("shell", keywords) # node gets an empty environment, or on Windows SystemRoot alone. self.assertEqual(ast.unparse(keywords["env"]), "{'SystemRoot': system_root} if system_root else {}") # stderr is discarded, and stdout is read through the bounded reader in Probe.node. self.assertEqual(ast.unparse(keywords["stderr"]), "subprocess.DEVNULL") + # podman runs only in the amend mode, from run_podman, with no shell and no input, and its argument list built there. + run = next(call for call in calls if call.func.attr == "run") + function = next(node for node in self.tree.body if isinstance(node, ast.FunctionDef) and node.name == "run_podman") + self.assertIn(run, list(ast.walk(function))) + keywords = {keyword.arg: keyword.value for keyword in run.keywords} + self.assertNotIn("shell", keywords) + self.assertEqual(ast.unparse(keywords["stdin"]), "subprocess.DEVNULL") + self.assertEqual(ast.unparse(run.args[0]), "[executable, *args]") def test_files_are_opened_for_reading_except_the_outputs(self): for node in ast.walk(self.tree): @@ -1723,9 +2064,12 @@ def test_files_are_opened_for_reading_except_the_outputs(self): mode = node.args[1].value if isinstance(node.args[1], ast.Constant) else None with self.subTest(line=node.lineno): self.assertIn(mode, ("rb", "a")) - main = next(node for node in self.tree.body if isinstance(node, ast.FunctionDef) and node.name == "main") + # Only the two command entry points write, and only the record they were given. + entries = [node for node in self.tree.body if isinstance(node, ast.FunctionDef) and node.name in ("main", "amend_main")] writers = [node for node in ast.walk(self.tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("write_text", "write_bytes", "mkdir", "unlink", "rmdir", "chmod")] - self.assertEqual([node.lineno for node in writers], [node.lineno for node in ast.walk(main) if node in writers]) + self.assertEqual(len(entries), 2) + self.assertEqual(sorted(node.lineno for node in writers), sorted(node.lineno for entry in entries for node in ast.walk(entry) if node in writers)) + self.assertTrue(all(ast.unparse(node.func.value) == "args.output" for node in writers if node.func.attr == "write_text" and node in list(ast.walk(entries[0])))) if __name__ == "__main__": diff --git a/scripts/ci/tests/test_python_abi_probe.py b/scripts/ci/tests/test_python_abi_probe.py new file mode 100644 index 0000000..7a93e99 --- /dev/null +++ b/scripts/ci/tests/test_python_abi_probe.py @@ -0,0 +1,627 @@ +"""Tests for scripts/ci/probe_python_abi.py. + +Most tests feed the probe made-up index metadata and tag lists, so they do not +depend on the interpreter that runs them. InterpreterTests check this +interpreter's own ABI facts, and run their CPython 3.15 and 3.15t cases only on +those interpreters (python-abi.yml runs them there). The probe needs packaging, +which the workflows install from the backend lock. From the repository root: + python -I scripts/ci/tests/test_python_abi_probe.py --verbose +""" + +import ast +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +import pathlib +import signal +import subprocess +import sys +import sysconfig +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "ci" / "probe_python_abi.py" +LOCK = ROOT / "backend" / "locks" / "cp312-linux-x86_64.txt" + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +probe = load("probe_python_abi", SCRIPT) +if probe.tags is None: + raise SystemExit("packaging is not installed; install it from the backend lock first (see the docstring)") + +SHA = {name: (name * 64)[:64] for name in "abcdef0123456789"} + + +def wheel(filename, sha="a", yanked=False, requires_python=None): + return {"filename": filename, "digests": {"sha256": SHA[sha]}, "yanked": yanked, "requires_python": requires_python} + + +def release(*entries): + return {"urls": [*entries, {"filename": "cbor2-6.1.5.tar.gz", "digests": {"sha256": SHA["f"]}}]} + + +class Distribution: + """An installed distribution as importlib.metadata shows it, with only what the probe reads.""" + + def __init__(self, name, version, requires=(), wheel_tags=(), files=()): + self.metadata = {"Name": name} + self.version = version + self.requires = list(requires) + self.wheel_tags = list(wheel_tags) + self.files = [pathlib.PurePosixPath(item) for item in files] + + def read_text(self, name): + return "".join(f"Tag: {tag}\n" for tag in self.wheel_tags) if name == "WHEEL" else None + + +# Tags as packaging lists them for CPython 3.15 on Linux x86-64, abridged and in its order of preference. +GIL_TAGS = ["cp315-cp315-manylinux_2_39_x86_64", "cp315-cp315-manylinux_2_28_x86_64", "cp315-abi3-manylinux_2_39_x86_64", + "cp315-abi3-manylinux_2_28_x86_64", "cp311-abi3-manylinux_2_28_x86_64", "cp39-abi3-manylinux_2_28_x86_64", + "cp315-none-manylinux_2_28_x86_64", "py3-none-manylinux_2_28_x86_64", "py3-none-any"] +FT_TAGS = ["cp315-cp315t-manylinux_2_39_x86_64", "cp315-cp315t-manylinux_2_28_x86_64", "cp315-abi3t-manylinux_2_39_x86_64", + "cp315-abi3t-manylinux_2_28_x86_64", "cp315-none-manylinux_2_28_x86_64", "py3-none-manylinux_2_28_x86_64", "py3-none-any"] + + +class LockTests(unittest.TestCase): + def test_the_backend_lock(self): + lock = probe.parse_lock(LOCK.read_text(encoding="utf-8")) + self.assertEqual(len(lock), 50) + for target in probe.TARGETS: + self.assertIn(target, lock) + self.assertEqual(lock["packaging"]["version"], "26.3") + self.assertIn("cffi", lock["pycparser"]["via"]) + # The "# via" graph, followed transitively: cryptography needs cffi, which needs pycparser. + self.assertEqual(probe.lock_dependencies(lock, "cryptography"), {"cffi", "pycparser"}) + self.assertIn("psycopg-binary", probe.lock_dependencies(lock, "psycopg")) + self.assertEqual(probe.lock_dependencies(lock, "packaging"), set()) + + def test_requirement_lines_keep_every_hash(self): + lock = probe.parse_lock(LOCK.read_text(encoding="utf-8")) + text = probe.requirement_text(lock, {"packaging"}) + again = probe.parse_lock(text) + self.assertEqual(set(again), {"packaging"}) + self.assertEqual(again["packaging"]["hashes"], lock["packaging"]["hashes"]) + self.assertTrue(text.startswith("packaging==26.3 \\\n --hash=sha256:")) + + def test_malformed_locks(self): + for text, message in ( + ("a==1 \\\n --hash=sha256:" + SHA["a"] + "\na==1 \\\n --hash=sha256:" + SHA["b"] + "\n", "twice"), + ("a==1\n # via b\n", "no hash"), + ("A_B==1 \\\n --hash=sha256:" + SHA["a"] + "\n", None), + ("a==1;rm \\\n --hash=sha256:" + SHA["a"] + "\n", "unexpected form"), + ): + with self.subTest(text=text): + if message is None: + self.assertIn("a-b", probe.parse_lock(text)) + else: + with self.assertRaisesRegex(probe.ProbeError, message): + probe.parse_lock(text) + + +class WheelFileTests(unittest.TestCase): + def test_wheels_of_a_release(self): + files = probe.wheel_files(release(wheel("cbor2-6.1.5-cp315-cp315t-manylinux_2_28_x86_64.whl"), + wheel("cbor2-6.1.5-py3-none-any.whl", "b", yanked=True)), "cbor2", "6.1.5") + self.assertEqual([item["filename"] for item in files], ["cbor2-6.1.5-cp315-cp315t-manylinux_2_28_x86_64.whl", "cbor2-6.1.5-py3-none-any.whl"]) + self.assertEqual(files[0]["tags"], ["cp315-cp315t-manylinux_2_28_x86_64"]) + self.assertIs(files[1]["yanked"], True) + # A compressed tag set expands to every tag it names. + files = probe.wheel_files(release(wheel("cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl")), "cryptography", "50.0.2") + self.assertEqual(files[0]["tags"], ["cp315-abi3-manylinux_2_28_x86_64", "cp315-abi3t-manylinux_2_28_x86_64"]) + + def test_metadata_that_does_not_match_the_release_is_refused(self): + for document, message in ( + (release(wheel("other-6.1.5-py3-none-any.whl")), "under cbor2"), + (release(wheel("cbor2-6.1.4-py3-none-any.whl")), "under cbor2"), + (release({"filename": "cbor2-6.1.5-py3-none-any.whl", "digests": {"sha256": "x"}}), "unexpected name or digest"), + (release({"filename": "cbor2-6.1.5-py3-none-any.whl;rm.whl", "digests": {"sha256": SHA["a"]}}), "unexpected name or digest"), + (release(wheel("cbor2-6.1.5.whl")), "cannot be read"), + ): + with self.subTest(document=document), self.assertRaisesRegex(probe.ProbeError, message): + probe.wheel_files(document, "cbor2", "6.1.5") + + def test_metadata_is_read_from_a_directory_and_bounded(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) + (path / "cbor2-6.1.5.json").write_text(json.dumps(release(wheel("cbor2-6.1.5-py3-none-any.whl"))), encoding="utf-8") + (path / "big-1.json").write_bytes(b" " * (probe.METADATA_LIMIT + 1)) + (path / "list-1.json").write_text("[]", encoding="utf-8") + self.assertEqual(len(probe.read_metadata("cbor2", "6.1.5", path)["urls"]), 2) + with self.assertRaisesRegex(probe.ProbeError, "larger than"): + probe.read_metadata("big", "1", path) + with self.assertRaisesRegex(probe.ProbeError, "lists no files"): + probe.read_metadata("list", "1", path) + + def test_the_index_is_asked_for_the_release_only(self): + requests = [] + + class Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def urlopen(request, timeout): + requests.append((request.full_url, dict(request.header_items()), timeout)) + return Response(json.dumps(release()).encode()) + + with mock.patch.object(probe.urllib.request, "urlopen", urlopen): + probe.read_metadata("cbor2", "6.1.5", None) + self.assertEqual(requests, [("https://pypi.org/pypi/cbor2/6.1.5/json", {"Accept": "application/json", "User-agent": "claus-ci-probe-python-abi"}, probe.FETCH_TIMEOUT)]) + with mock.patch.object(probe.urllib.request, "urlopen", side_effect=OSError("down")), mock.patch.object(probe.time, "sleep") as sleep: + with self.assertRaisesRegex(probe.ProbeError, r"cannot read the index metadata of cbor2 6\.1\.5 \(OSError\)"): + probe.read_metadata("cbor2", "6.1.5", None) + self.assertEqual(sleep.call_count, probe.FETCH_ATTEMPTS - 1) + + +class TagTests(unittest.TestCase): + def files(self, *names): + return probe.wheel_files(release(*[wheel(name, sha) for name, sha in names]), names[0][0].split("-")[0].lower(), names[0][0].split("-")[1]) + + def test_wheels_this_interpreter_accepts_in_its_order(self): + files = self.files(("cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", "a"), + ("cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_39_x86_64.whl", "b"), + ("cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", "c"), + ("cryptography-50.0.2-cp311-abi3-win_arm64.whl", "d")) + in_lock, not_in_lock = probe.compatible(files, GIL_TAGS, {SHA["a"], SHA["c"]}) + self.assertEqual([item["filename"] for item in in_lock], ["cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl"]) + self.assertEqual([item["filename"] for item in not_in_lock], ["cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_39_x86_64.whl"]) + # A free-threaded interpreter takes the abi3t half of the dual tag, and no abi3 wheel. + in_lock, _ = probe.compatible(files, FT_TAGS, {SHA["a"], SHA["c"]}) + self.assertEqual([item["filename"] for item in in_lock], ["cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl"]) + # A yanked wheel is not chosen. + files[0]["yanked"] = True + self.assertEqual(probe.compatible(files[:1], GIL_TAGS, {SHA["a"]}), ([], [])) + + def test_requires_python_excludes_a_wheel_as_installers_do(self): + files = probe.wheel_files(release(wheel("cbor2-6.1.5-py3-none-any.whl", "a", requires_python=">=3.16"), + wheel("cbor2-6.1.5-cp315-cp315-manylinux_2_28_x86_64.whl", "b", requires_python=">=3.10"), + wheel("cbor2-6.1.5-cp39-abi3-manylinux_2_28_x86_64.whl", "c", requires_python="not a specifier")), "cbor2", "6.1.5") + self.assertEqual([item["requires_python"] for item in files], [">=3.16", ">=3.10", "not a specifier"]) + in_lock, _ = probe.compatible(files, GIL_TAGS, {SHA["a"], SHA["b"], SHA["c"]}, "3.15.0") + # The py3 wheel needs 3.16; an unreadable Requires-Python excludes nothing, as pip treats it. + self.assertEqual([item["sha256"] for item in in_lock], [SHA["b"], SHA["c"]]) + in_lock, _ = probe.compatible(files, GIL_TAGS, {SHA["a"], SHA["b"], SHA["c"]}, "3.16.0") + self.assertEqual([item["sha256"] for item in in_lock], [SHA["b"], SHA["c"], SHA["a"]]) + self.assertTrue(probe.admits(None, "3.15.0")) + + def test_abi_categories(self): + files = self.files(("cbor2-6.1.5-cp315-cp315-manylinux_2_28_x86_64.whl", "a"), ("cbor2-6.1.5-cp315-cp315t-manylinux_2_28_x86_64.whl", "b"), + ("cbor2-6.1.5-cp315-cp315t-win_arm64.whl", "c"), ("cbor2-6.1.5-cp39-abi3-manylinux_2_28_x86_64.whl", "d")) + gil = probe.abi_categories(files, GIL_TAGS, "cp315") + self.assertEqual(gil["cp315-cp315"], {"published": True, "accepted": True}) + self.assertEqual(gil["cp315-cp315t"], {"published": True, "accepted": False}) + self.assertEqual(gil["cp315-abi3"], {"published": False, "accepted": True}) + self.assertEqual(gil["cp315-abi3.abi3t"], {"published": False, "accepted": True}) + self.assertEqual(gil["older"], [{"tag": "cp39-abi3", "published": True, "accepted": True}]) + ft = probe.abi_categories(files, FT_TAGS, "cp315") + self.assertEqual(ft["cp315-cp315t"], {"published": True, "accepted": True}) + self.assertEqual(ft["cp315-abi3"], {"published": False, "accepted": False}) + self.assertEqual(ft["cp315-abi3t"], {"published": False, "accepted": True}) + # An older abi3 wheel is published but the free-threaded build cannot load it. + self.assertEqual(ft["older"], [{"tag": "cp39-abi3", "published": True, "accepted": False}]) + # A wheel for another platform does not count as published for this one. + self.assertEqual(probe.abi_categories(files[2:3], GIL_TAGS, "cp315")["cp315-cp315t"]["published"], False) + dual = self.files(("cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", "a")) + self.assertEqual(probe.abi_categories(dual, FT_TAGS, "cp315")["cp315-abi3.abi3t"], {"published": True, "accepted": True}) + self.assertEqual(probe.abi_categories(dual, GIL_TAGS, "cp315")["cp315-abi3.abi3t"], {"published": True, "accepted": True}) + + def test_the_selected_wheel_is_found_by_its_tags(self): + files = self.files(("cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", "a"), + ("cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", "b"), + ("cryptography-50.0.2-1-cp311-abi3-manylinux_2_28_x86_64.whl", "c")) + chosen = probe.selected_wheel(["cp315-abi3t-manylinux_2_28_x86_64", "cp315-abi3-manylinux_2_28_x86_64"], files, {SHA["a"]}) + self.assertEqual(chosen["sha256"], SHA["a"]) + # Two files with the same tags (a build number) are told apart by the lock, or not at all. + self.assertEqual(probe.selected_wheel(["cp311-abi3-manylinux_2_28_x86_64"], files, {SHA["c"]})["sha256"], SHA["c"]) + self.assertIsNone(probe.selected_wheel(["cp311-abi3-manylinux_2_28_x86_64"], files, {SHA["b"], SHA["c"]})) + self.assertIsNone(probe.selected_wheel(["cp315-cp315t-manylinux_2_28_x86_64"], files, {SHA["a"]})) + + +class OutcomeTests(unittest.TestCase): + def test_a_missing_wheel_is_not_an_install_error(self): + self.assertEqual(probe.install_status(0, ["w.whl"], []), "installed") + self.assertEqual(probe.install_status(1, [], []), "no-wheel") + self.assertEqual(probe.install_status(1, [], ["late.whl"]), "not-in-lock") + # The lock holds a wheel this interpreter accepts, and the install still failed: unexpected. + self.assertEqual(probe.install_status(1, ["w.whl"], []), "error") + self.assertEqual(probe.install_status(None, ["w.whl"], []), "error") + # The install passed although no wheel was predicted: the report contradicts itself. + self.assertEqual(probe.install_status(0, [], []), "contradiction") + + def test_the_overall_outcome(self): + def item(install, imported="not-run", called="not-run"): + return {"name": install, "install": {"status": install}, "probe": {"import": imported, "call": called}} + + self.assertEqual(probe.overall([item("installed", "passed", "passed")])[0::2], ("passed", "passed")) + self.assertEqual(probe.overall([item("installed", "passed", "passed"), item("no-wheel")])[0::2], ("blocked", "passed")) + self.assertEqual(probe.overall([item("installed", "failed"), item("no-wheel")])[0::2], ("blocked", "failed")) + self.assertEqual(probe.overall([item("error"), item("no-wheel")])[0::2], ("failed", "not-run")) + self.assertEqual(probe.overall([item("dependency-blocked")])[0], "blocked") + self.assertEqual(probe.overall([item("dependency-failed")])[0], "failed") + self.assertEqual(probe.overall([item("contradiction")])[0], "failed") + # A package that enables the GIL a free-threaded build started without fails, though it imports and runs. + reenabled = item("installed", "passed", "passed") + reenabled["probe"]["gil_reenabled"] = True + self.assertEqual(probe.overall([reenabled])[0::2], ("passed", "failed")) + # So does one whose process printed a passing result and then did not exit with status 0. + crashed = item("installed", "passed", "passed") + crashed["probe"]["exit"] = {"status": "failed", "returncode": -11, "signal": "SIGSEGV"} + self.assertEqual(probe.overall([crashed])[0::2], ("passed", "failed")) + clean = item("installed", "passed", "passed") + clean["probe"]["exit"] = {"status": "passed", "returncode": 0} + self.assertEqual(probe.overall([clean])[0::2], ("passed", "passed")) + + def test_the_install_stage(self): + wheels = {"uncovered": ["cryptography", "psycopg-binary"], "published_not_in_lock": ["psycopg-binary"]} + named = "Because cryptography==50.0.2 has no wheels with a matching platform tag (e.g., `win_arm64`), we can conclude..." + status, reason = probe.install_stage(1, None, named, wheels) + self.assertEqual(status, "blocked") + self.assertIn("no wheel this interpreter can install for cryptography, psycopg-binary", reason) + self.assertIn("published after the lock for psycopg-binary", reason) + self.assertEqual(probe.install_stage(1, None, named.replace("cryptography", "psycopg_binary"), wheels)[0], "blocked") + # A failure whose output names no such package, such as a network error, is not explained by the report. + self.assertEqual(probe.install_stage(1, None, "error: Failed to fetch: connection reset", wheels)[0], "failed") + self.assertEqual(probe.install_stage(1, None, "Because pycryptography has no wheels", {"uncovered": ["cryptography"]})[0], "failed") + self.assertEqual(probe.install_stage(1, None, named, {"uncovered": []})[0], "failed") + self.assertEqual(probe.install_stage(0, 0, "", wheels)[0], "failed") + self.assertEqual(probe.install_stage(0, 0, "", None)[0], "failed") + self.assertEqual(probe.install_stage(0, 0, "", {"uncovered": []}), ("passed", "the lock installed binary-only with its hashes, and uv pip check passed")) + # Windows x64: every wheel exists, and tzdata, which Django and psycopg require on win32, is not in the lock. + # The log is what uv 0.12.24 printed on that lane. + missing = {"missing": [{"requirement": "tzdata", "required_by": "django", "in_lock": False, "installed": None}, + {"requirement": "tzdata", "required_by": "psycopg", "in_lock": False, "installed": None}]} + check_log = ("Checked 50 packages in 2ms\nFound 2 incompatibilities\n" + "The package `psycopg` requires `tzdata ; sys_platform == 'win32'`, but it's not installed\n" + "The package `django` requires `tzdata ; sys_platform == 'win32'`, but it's not installed\n") + self.assertEqual(probe.install_stage(0, 1, check_log, {"uncovered": []}, missing), + ("blocked", "the lock does not pin requirements this platform needs: tzdata (required by django, psycopg)")) + # A requirement the lock pins but the install lacks, or a check failure nothing explains, is a failure. + self.assertEqual(probe.install_stage(0, 1, check_log, {"uncovered": []}, {"missing": [{"requirement": "idna", "required_by": "anyio", "in_lock": True}, *missing["missing"]]})[0], "failed") + self.assertEqual(probe.install_stage(0, 1, check_log, {"uncovered": []}, {"missing": []})[0], "failed") + self.assertEqual(probe.install_stage(0, 1, check_log, {"uncovered": []}, None)[0], "failed") + # An installed version a requirement excludes is a failure, even beside a requirement the lock lacks. + conflict = {"requirement": "asgiref", "required_by": "django", "in_lock": True, "installed": "3.8.0"} + self.assertEqual(probe.install_stage(0, 1, check_log, {"uncovered": []}, {"missing": [conflict, *missing["missing"]]})[0], "failed") + # So is a problem uv pip check reports that the requirements report does not model, such as a version + # conflict, or a count that does not match the problem lines, or no problem lines at all. + mixed = check_log.replace("Found 2 incompatibilities", "Found 3 incompatibilities") + "The package `django` requires `asgiref>=3.9`, but `3.8.0` is installed\n" + self.assertEqual(probe.install_stage(0, 1, mixed, {"uncovered": []}, missing), + ("failed", "uv pip check reported 1 of 3 problems that are not a requirement missing from the lock; see the install log")) + self.assertEqual(probe.install_stage(0, 1, check_log.replace("Found 2", "Found 3"), {"uncovered": []}, missing)[0], "failed") + self.assertEqual(probe.install_stage(0, 1, "", {"uncovered": []}, missing)[0], "failed") + self.assertEqual(probe.install_stage(0, 1, check_log.replace("tzdata ;", "tzdata2 ;"), {"uncovered": []}, missing)[0], "failed") + + def test_only_a_line_that_says_a_wheel_is_missing_names_one(self): + # What uv 0.12.24 printed on the macOS x64 and Windows on Arm lanes. + log = (" cause: Because autobahn==26.7.1 has no usable wheels and you require autobahn==26.7.1, we can conclude that your requirements are unsatisfiable.\n" + "hint: Wheels are required for `autobahn` because building from source is disabled for all packages (i.e., with `--no-build`)\n") + self.assertTrue(probe.names_a_missing_wheel(log, ["autobahn"])) + self.assertTrue(probe.names_a_missing_wheel(log.splitlines()[1], ["autobahn"])) + self.assertFalse(probe.names_a_missing_wheel(log, ["cryptography"])) + # A line about a wheel that does not say it is missing, such as a failed download, names none. + self.assertFalse(probe.names_a_missing_wheel("error: Failed to download `autobahn` wheel: connection reset\n", ["autobahn"])) + + def test_the_install_stage_command(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) + (path / "wheels.json").write_text(json.dumps({"wheels": {"uncovered": ["cryptography"]}}), encoding="utf-8") + (path / "log.txt").write_text("cryptography==50.0.2 has no wheels with a matching platform tag\n", encoding="utf-8") + output = path / "output" + argv = ["install-stage", "--sync", "1", "--check", "skip", "--log", str(path / "log.txt"), "--wheels", str(path / "wheels.json"), "--github-output", str(output)] + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(probe.main(argv), 0) + self.assertEqual(output.read_text(encoding="utf-8").splitlines(), ["status=blocked", "reason=the lock has no wheel this interpreter can install for cryptography"]) + (path / "log.txt").write_text("boom\n", encoding="utf-8") + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(probe.main(argv), 1) + # A missing wheels report is a failed stage, not a usage error. + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(probe.main([*argv[:-4], "--wheels", str(path / "missing.json")]), 1) + for bad in (["--sync", "x"], ["--check", "1;x"]): + with self.subTest(bad=bad), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + probe.main([*argv, *bad]) + + def test_output_lines_cannot_be_forged(self): + with tempfile.TemporaryDirectory() as directory, self.assertRaisesRegex(probe.ProbeError, "spans lines"): + probe.write_output(pathlib.Path(directory) / "o", [("reason", "x\nstatus=passed")]) + + def test_recorded_text_has_no_paths_or_control_characters(self): + text = probe.scrub(f"DeprecationWarning: {sys.prefix}/lib\nnext\tline\x1b[0m" + "x" * 400) + self.assertTrue(text.startswith("DeprecationWarning: /lib next line [0m")) + self.assertEqual(len(text), 300) + + +class RequirementTests(unittest.TestCase): + def test_unmet_requirements_name_missing_and_excluded_versions(self): + environment = {**probe.markers.default_environment(), "sys_platform": "win32"} + installed = [Distribution("Django", "6.1", ["asgiref>=3.9", "sqlparse>=0.5", "tzdata; sys_platform == 'win32'", "argon2-cffi>=23; extra == 'argon2'"]), + Distribution("asgiref", "3.8.0"), Distribution("tzdata-free", "1.0", ["Django>=6"])] + with mock.patch.object(probe.importlib.metadata, "distributions", return_value=installed), \ + mock.patch.object(probe.markers, "default_environment", return_value=environment): + missing = probe.unmet_requirements({"django": {}, "asgiref": {}, "sqlparse": {}}) + # A requirement of an extra nobody asked for is not counted; a met one is not listed. + self.assertEqual(missing, [ + {"requirement": "asgiref", "required_by": "django", "in_lock": True, "installed": "3.8.0"}, + {"requirement": "sqlparse", "required_by": "django", "in_lock": True, "installed": None}, + {"requirement": "tzdata", "required_by": "django", "in_lock": False, "installed": None}, + ]) + + +class PackageProbeTests(unittest.TestCase): + """probe_package and the packages command, with uv, the installed distributions and the import probe replaced.""" + + LOCK_TEXT = (f"cffi==2.0.0 \\\n --hash=sha256:{SHA['a']}\n # via cryptography\n" + f"pycparser==2.23 \\\n --hash=sha256:{SHA['b']}\n # via cffi\n") + CFFI = "cffi-2.0.0-cp315-cp315-manylinux_2_28_x86_64.whl" + # An extension module suffix of the interpreter that runs the tests, such as .cpython-315t-darwin.so or .cp315-win_amd64.pyd. + SUFFIX = importlib.machinery.EXTENSION_SUFFIXES[0] + PASSED = {"gil_before": True, "import": "passed", "gil_after_import": True, "call": "passed", "gil_after_call": True, "warnings": [], "gil_reenabled": False} + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.path = pathlib.Path(directory.name) + self.metadata(wheel(self.CFFI, "a")) + (self.path / "pycparser-2.23.json").write_text(json.dumps({"urls": [wheel("pycparser-2.23-py3-none-any.whl", "b")]}), encoding="utf-8") + # Bytes, so that Windows does not write \r\n and change the lock's hash. + (self.path / "lock.txt").write_bytes(self.LOCK_TEXT.encode("utf-8")) + self.lock = probe.parse_lock(self.LOCK_TEXT) + self.installed = {} + self.uv_calls = [] + + def metadata(self, *entries): + probe._METADATA.clear() + (self.path / "cffi-2.0.0.json").write_text(json.dumps({"urls": list(entries)}), encoding="utf-8") + + def run_probe(self, uv_status=(0, 0), probe_out=None, unmet=()): + statuses = list(uv_status) + + def run_uv(uv, lock, names): + self.uv_calls.append(sorted(names)) + status = statuses.pop(0) + if status == 0: + for name in names: + self.installed[name] = Distribution(name, lock[name]["version"], wheel_tags=["cp315-cp315-manylinux_2_28_x86_64"] if name == "cffi" else ["py3-none-any"], + files=[f"_cffi_backend{self.SUFFIX}"] if name == "cffi" else ["pycparser/__init__.py"]) + return status + + with mock.patch.object(probe, "run_uv", run_uv), mock.patch.object(probe, "installed_distribution", lambda name: self.installed.get(name)), \ + mock.patch.object(probe, "unmet_requirements", return_value=list(unmet)), \ + mock.patch.object(probe, "import_probe", return_value=dict(probe_out or self.PASSED)) as import_probe: + item = probe.probe_package("cffi", self.lock, "uv", GIL_TAGS, self.path) + return item, import_probe.call_count + + def test_an_installed_package_is_imported_and_called(self): + item, imports = self.run_probe() + self.assertEqual(self.uv_calls, [["cffi"], ["pycparser"]]) + self.assertEqual(item["install"], {"status": "installed"}) + self.assertEqual(item["selected"], {"filename": self.CFFI, "sha256": SHA["a"], "in_lock": True, "tags": ["cp315-cp315-manylinux_2_28_x86_64"], "preferred": True}) + self.assertEqual(item["extension_suffixes"], [self.SUFFIX]) + self.assertEqual((item["probe"]["import"], item["probe"]["call"], imports), ("passed", "passed", 1)) + self.assertEqual(item["thread_safety"]["status"], "not-tested") + + def test_install_outcomes(self): + # A dependency that has a wheel here and still fails to install is an unexpected failure. + item, imports = self.run_probe(uv_status=(0, 1)) + self.assertEqual((item["install"], imports), ({"status": "dependency-failed", "dependencies": ["pycparser"]}, 0)) + # A requirement the lock does not pin is a missing prerequisite; one whose installed version is excluded is not. + self.installed.clear() + item, _ = self.run_probe(unmet=[{"requirement": "tzdata", "required_by": "cffi", "in_lock": False, "installed": None}]) + self.assertEqual(item["install"], {"status": "dependency-blocked", "dependencies": ["tzdata"]}) + self.installed.clear() + item, _ = self.run_probe(unmet=[{"requirement": "pycparser", "required_by": "cffi", "in_lock": True, "installed": "2.22"}]) + self.assertEqual(item["install"]["status"], "dependency-failed") + # No wheel for this interpreter: uv is asked once and nothing is imported. + self.installed.clear() + self.uv_calls.clear() + self.metadata(wheel("cffi-2.0.0-cp315-cp315-win_arm64.whl", "a")) + item, imports = self.run_probe(uv_status=(1,)) + self.assertEqual((item["install"], item["probe"]["import"], imports, self.uv_calls), ({"status": "no-wheel"}, "not-run", 0, [["cffi"]])) + + def packages(self, probe_out=None): + argv = ["packages", "--lock", str(self.path / "lock.txt"), "--uv", "uv", "--output", str(self.path / "out.json"), + "--metadata-dir", str(self.path), "--target", "cffi", "--github-output", str(self.path / "github")] + (self.path / "github").write_text("", encoding="utf-8") + statuses = [0, 0] + + def run_uv(uv, lock, names): + status = statuses.pop(0) + for name in names: + self.installed[name] = Distribution(name, lock[name]["version"], wheel_tags=["cp315-cp315-manylinux_2_28_x86_64"] if name == "cffi" else ["py3-none-any"]) + return status + + with mock.patch.object(probe, "interpreter_tags", return_value=GIL_TAGS), mock.patch.object(probe, "run_uv", run_uv), \ + mock.patch.object(probe, "installed_distribution", lambda name: self.installed.get(name)), \ + mock.patch.object(probe, "unmet_requirements", return_value=[]), \ + mock.patch.object(probe, "import_probe", return_value=dict(probe_out or self.PASSED)), contextlib.redirect_stdout(io.StringIO()): + status = probe.main(argv) + report = json.loads((self.path / "out.json").read_text(encoding="utf-8")) + return status, report, (self.path / "github").read_text(encoding="utf-8").splitlines() + + def test_the_packages_command(self): + status, report, outputs = self.packages() + self.assertEqual(status, 0) + self.assertEqual(report["schema"], "claus-python-abi/1") + self.assertEqual(report["lock"]["sha256"], probe.hashlib.sha256(self.LOCK_TEXT.encode()).hexdigest()) + self.assertEqual(report["wheels"]["uncovered"], []) + self.assertEqual(report["packages"]["outcome"], {"install": "passed", "install_reason": "every package installed", "execute": "passed"}) + self.assertEqual(outputs, ["install=passed", "install-reason=every package installed", "execute=passed"]) + # A package that enables the GIL again fails the command, though its import and call passed. + self.installed.clear() + status, report, outputs = self.packages({**self.PASSED, "gil_before": False, "gil_after_import": True, "gil_after_call": True, "gil_reenabled": True}) + self.assertEqual(status, 1) + self.assertEqual(outputs[-1], "execute=failed") + self.assertIn("| cffi 2.0.0 | installed | " + self.CFFI + " | - | False / True | passed | passed | - |", "\n".join(probe.summary_lines(report))) + # A package whose probe process printed a passing result and was then killed by a signal fails the command too. + self.installed.clear() + status, report, outputs = self.packages({**self.PASSED, "exit": {"status": "failed", "returncode": -11, "signal": "SIGSEGV"}}) + self.assertEqual((status, outputs[-1]), (1, "execute=failed")) + self.assertEqual(report["packages"]["packages"][0]["probe"]["exit"], {"status": "failed", "returncode": -11, "signal": "SIGSEGV"}) + self.assertIn("| True / True | passed | passed | SIGSEGV |", "\n".join(probe.summary_lines(report))) + + +class ImportProbeTests(unittest.TestCase): + def run_target(self, code_import, code_call): + with mock.patch.dict(probe.TARGETS, {"x-test": (code_import, code_call)}): + return probe.import_probe("x-test") + + def test_an_import_and_a_call_in_a_new_process(self): + out = self.run_target("import json", "assert json.loads('[1]') == [1]") + self.assertEqual((out["import"], out["call"]), ("passed", "passed")) + self.assertEqual(out["exit"], {"status": "passed", "returncode": 0}) + expected = sys._is_gil_enabled() if hasattr(sys, "_is_gil_enabled") else None + if sysconfig.get_config_var("Py_GIL_DISABLED") != 1: + self.assertEqual((out["gil_before"], out["gil_after_call"]), (expected, expected)) + self.assertIs(out["gil_reenabled"], False) + + def test_failures_are_kept_apart(self): + out = self.run_target("import claus_no_such_module", "pass") + self.assertEqual((out["import"], out["import_error"], out["call"]), ("failed", "ModuleNotFoundError", "not-run")) + out = self.run_target("import json", "json.loads('{')") + self.assertEqual((out["import"], out["call"], out["call_error"]), ("passed", "failed", "JSONDecodeError")) + out = self.run_target("import warnings; warnings.warn('careful')", "pass") + self.assertEqual(out["warnings"], ["UserWarning: careful"]) + + def test_the_exit_status_is_checked_after_the_result(self): + # The process prints a passing import and call; how it then exits decides whether the run passed. + printed = json.dumps({"gil_before": True, "import": "passed", "gil_after_import": True, "call": "passed", "gil_after_call": True, "warnings": []}) + + def ended(returncode): + done = subprocess.CompletedProcess([], returncode, stdout=f"{printed}\n".encode(), stderr=b"") + with mock.patch.object(probe.subprocess, "run", return_value=done): + out = self.run_target("pass", "pass") + # The printed result is kept as evidence, whatever the exit. + self.assertEqual((out["import"], out["call"]), ("passed", "passed")) + return out["exit"] + + self.assertEqual(ended(0), {"status": "passed", "returncode": 0}) + self.assertEqual(ended(3), {"status": "failed", "returncode": 3}) + # POSIX reports a signal as a negative status; Windows reports a crash such as 0xC0000005 as a large positive one. + self.assertEqual(ended(-11), {"status": "failed", "returncode": -11, "signal": "SIGSEGV"}) + self.assertEqual(ended(-200), {"status": "failed", "returncode": -200, "signal": "200"}) + self.assertEqual(ended(0xC0000005), {"status": "failed", "returncode": 3221225477}) + + def test_a_nonzero_exit_after_the_result_fails(self): + # The call passes, the result is printed and flushed, and the process then exits with status 3 at shutdown. + out = self.run_target("import atexit, os, sys", "atexit.register(lambda: (sys.stdout.flush(), os._exit(3)))") + self.assertEqual((out["import"], out["call"], out["exit"]), ("passed", "passed", {"status": "failed", "returncode": 3})) + item = {"name": "x-test", "install": {"status": "installed"}, "probe": out} + self.assertEqual(probe.overall([item])[0::2], ("passed", "failed")) + + @unittest.skipIf(os.name == "nt", "POSIX signals") + def test_a_signal_after_the_result_fails(self): + out = self.run_target("import atexit, os, signal, sys", "atexit.register(lambda: (sys.stdout.flush(), os.kill(os.getpid(), signal.SIGKILL)))") + self.assertEqual((out["import"], out["call"]), ("passed", "passed")) + self.assertEqual(out["exit"], {"status": "failed", "returncode": -signal.SIGKILL, "signal": "SIGKILL"}) + item = {"name": "x-test", "install": {"status": "installed"}, "probe": out} + self.assertEqual(probe.overall([item])[0::2], ("passed", "failed")) + + def test_a_process_that_prints_no_result_fails(self): + out = self.run_target("import os", "os._exit(4)") + self.assertEqual((out["import"], out["import_error"], out["call"]), ("failed", "no result", "not-run")) + self.assertEqual(out["exit"], {"status": "failed", "returncode": 4}) + + +class InterpreterTests(unittest.TestCase): + """This interpreter's own ABI facts, as the probe records them.""" + + facts = probe.interpreter_facts() + supported = probe.interpreter_tags() + version = f"cp{sys.version_info[0]}{sys.version_info[1]}" + free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + + def test_the_facts_agree_with_each_other(self): + facts = self.facts + self.assertEqual(facts["version"], ".".join(str(part) for part in sys.version_info[:3])) + self.assertEqual(facts["extension_suffixes"], list(importlib.machinery.EXTENSION_SUFFIXES)) + if facts["soabi"]: + # cpython-315t-x86_64-linux-gnu, cpython-315t-darwin, cp315t-win_amd64: the version part ends with t on a free-threaded build. + part = facts["soabi"].split("-")[1] if facts["soabi"].startswith("cpython-") else facts["soabi"].split("-")[0] + self.assertEqual(part.endswith("t"), self.free_threaded) + if hasattr(sys, "_is_gil_enabled") and not self.free_threaded: + self.assertIs(facts["gil_enabled"], True) + home = str(pathlib.Path.home()) + if len(home) > 5: + self.assertNotIn(home, json.dumps(facts)) + + def test_the_tags_match_the_build(self): + own = [tag for tag in self.supported if tag.startswith(f"{self.version}-{self.version}")] + self.assertTrue(own) + self.assertEqual({tag.split("-")[1] for tag in own}, {f"{self.version}t" if self.free_threaded else self.version}) + + @unittest.skipUnless(sys.version_info[:2] == (3, 15) and not sysconfig.get_config_var("Py_GIL_DISABLED"), "needs CPython 3.15 with the GIL") + def test_cpython_3_15_with_the_gil(self): + abis = {tag.split("-")[1] for tag in self.supported if tag.startswith("cp3")} + self.assertIn("abi3", abis) + self.assertNotIn("cp315t", abis) + self.assertIs(sys._is_gil_enabled(), True) + self.assertEqual(self.facts["py_gil_disabled"], 0) + if os.name == "posix": + self.assertIn(".abi3.so", importlib.machinery.EXTENSION_SUFFIXES) + + @unittest.skipUnless(sys.version_info[:2] == (3, 15) and sysconfig.get_config_var("Py_GIL_DISABLED") == 1, "needs CPython 3.15t") + def test_cpython_3_15_free_threaded(self): + abis = {tag.split("-")[1] for tag in self.supported if tag.startswith("cp3")} + # The free-threaded build takes cp315t and abi3t wheels, and no abi3 wheel of any version. + self.assertIn("cp315t", abis) + self.assertIn("abi3t", abis) + self.assertNotIn("abi3", abis) + self.assertNotIn("cp315", abis) + self.assertIs(sys._is_gil_enabled(), False) + self.assertEqual(self.facts["py_gil_disabled"], 1) + if os.name == "posix": + self.assertNotIn(".abi3.so", importlib.machinery.EXTENSION_SUFFIXES) + self.assertIn(".abi3t.so", importlib.machinery.EXTENSION_SUFFIXES) + + +class SourceTests(unittest.TestCase): + """The probe's own code: no shell, no other network host, and writes only where it is told to.""" + + tree = ast.parse(SCRIPT.read_text(encoding="utf-8")) + + def calls(self, module): + return [node for node in ast.walk(self.tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == module] + + def test_subprocesses_without_a_shell_or_input(self): + runs = self.calls("subprocess") + self.assertEqual([call.func.attr for call in runs], ["run", "run"]) + for call in runs: + keywords = {keyword.arg: ast.unparse(keyword.value) for keyword in call.keywords} + self.assertNotIn("shell", keywords) + self.assertEqual(keywords["stdin"], "subprocess.DEVNULL") + self.assertIn("timeout", keywords) + # uv installs binary only, from the lock's hashes, and reads no uv.toml or pyproject.toml. + uv = next(node for node in ast.walk(self.tree) if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "command" and "pip" in ast.unparse(node.value)) + text = ast.unparse(uv.value) + for flag in ("'--no-config'", "'--require-hashes'", "'--only-binary'", "':all:'", "'--no-deps'"): + self.assertIn(flag, text) + + def test_the_only_network_use_is_the_index_json_api(self): + urls = [node for node in ast.walk(self.tree) if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value.startswith(("http://", "https://"))] + self.assertEqual(sorted(node.value for node in urls), ["https://pypi.org/pypi/{name}/{version}/json", "https://pypi.org/simple"]) + self.assertEqual([ast.unparse(call.func) for call in ast.walk(self.tree) if isinstance(call, ast.Call) and "urlopen" in ast.unparse(call.func)], + ["urllib.request.urlopen"]) + imported = {alias.name.split(".")[0] for node in ast.walk(self.tree) if isinstance(node, ast.Import) for alias in node.names} + self.assertEqual(imported & {"socket", "http", "requests", "ssl", "ctypes"}, set()) + + def test_no_environment_is_read_or_changed(self): + names = {ast.unparse(node) for node in ast.walk(self.tree) if isinstance(node, ast.Attribute)} + self.assertEqual({name for name in names if name.startswith("os.environ") or name in ("os.getenv", "os.putenv", "os.system", "os.popen")}, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compatibility/profiles/gha-linux-arm64-cp312.json b/tests/compatibility/profiles/gha-linux-arm64-cp312.json new file mode 100644 index 0000000..8ba28c4 --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-arm64-cp312.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-arm64-cp312", + "title": "Linux arm64 on a GitHub-hosted ubuntu-24.04-arm runner, backend and frontend", + "description": "The platform job of .github/workflows/compatibility.yml on a native Arm64 ubuntu-24.04-arm virtual machine: an aarch64 CPython 3.12.15 and an arm64 Node 24.21.0, no emulation, as a non-root user, CPU only. A pass is evidence for the lane table, not a status change.", + "support": { + "status": "statically-resolvable", + "lane": "Linux aarch64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-aarch64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-aarch64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "linux", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-linux-arm64-cp315.json b/tests/compatibility/profiles/gha-linux-arm64-cp315.json new file mode 100644 index 0000000..132b0ce --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-arm64-cp315.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-arm64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted ubuntu-24.04-arm runner", + "description": "The abi job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on ubuntu-24.04-arm, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "Linux aarch64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-315-aarch64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-aarch64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-linux-arm64-cp315t.json b/tests/compatibility/profiles/gha-linux-arm64-cp315t.json new file mode 100644 index 0000000..341f0f2 --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-arm64-cp315t.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-arm64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted ubuntu-24.04-arm runner", + "description": "The abi job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 (3.15t) from uv 0.13.0 on ubuntu-24.04-arm, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "Linux aarch64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-315t-aarch64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-aarch64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-linux-x64-cp312.json b/tests/compatibility/profiles/gha-linux-x64-cp312.json new file mode 100644 index 0000000..4b66660 --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-x64-cp312.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-x64-cp312", + "title": "Linux x86-64 on a GitHub-hosted ubuntu-24.04 runner, backend and frontend", + "description": "The platform job of .github/workflows/compatibility.yml: the hashed backend lock, Django check, the Django tests and pytest with CPython 3.12.15 from uv, and the frontend checks with Node 24.21.0, on an ubuntu-24.04 x86-64 virtual machine as a non-root user, CPU only.", + "support": { + "status": "checked", + "lane": "Linux x86-64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-x86_64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-x86_64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "linux", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-linux-x64-cp315.json b/tests/compatibility/profiles/gha-linux-x64-cp315.json new file mode 100644 index 0000000..118d2c9 --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-x64-cp315.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-x64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted ubuntu-24.04 runner", + "description": "The abi job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on ubuntu-24.04, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "Linux x86-64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-315-x86_64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-x86_64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-linux-x64-cp315t.json b/tests/compatibility/profiles/gha-linux-x64-cp315t.json new file mode 100644 index 0000000..b4cb042 --- /dev/null +++ b/tests/compatibility/profiles/gha-linux-x64-cp315t.json @@ -0,0 +1,269 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-linux-x64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted ubuntu-24.04 runner", + "description": "The abi job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 (3.15t) from uv 0.13.0 on ubuntu-24.04, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "Linux x86-64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "ubuntu", + "provenance": "declared" + }, + "version": { + "value": "24.04", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": "systemd", + "provenance": "declared" + }, + "scope": { + "value": "system", + "provenance": "declared" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-315t-x86_64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-x86_64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-arm64-cp312.json b/tests/compatibility/profiles/gha-macos-arm64-cp312.json new file mode 100644 index 0000000..fc80a86 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-arm64-cp312.json @@ -0,0 +1,275 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-arm64-cp312", + "title": "macOS on Apple Silicon on a GitHub-hosted macos-15 runner, backend and frontend", + "description": "The platform job of .github/workflows/compatibility.yml on a macos-15 Arm64 virtual machine with an aarch64 CPython 3.12.15 and an arm64 Node 24.21.0, as a non-root user. The lane's statuses depend on the macOS version, which changes with image updates and is recorded by each run rather than declared, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "macOS Apple Silicon", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "darwin", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-arm64-cp315.json b/tests/compatibility/profiles/gha-macos-arm64-cp315.json new file mode 100644 index 0000000..1b84b11 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-arm64-cp315.json @@ -0,0 +1,275 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-arm64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted macos-15 runner", + "description": "The abi job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on macos-15, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "macOS Apple Silicon", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-arm64-cp315t.json b/tests/compatibility/profiles/gha-macos-arm64-cp315t.json new file mode 100644 index 0000000..c9985a5 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-arm64-cp315t.json @@ -0,0 +1,275 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-arm64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted macos-15 runner", + "description": "The abi job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 (3.15t) from uv 0.13.0 on macos-15, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "macOS Apple Silicon", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-x64-cp312.json b/tests/compatibility/profiles/gha-macos-x64-cp312.json new file mode 100644 index 0000000..9c45bd8 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-x64-cp312.json @@ -0,0 +1,271 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-x64-cp312", + "title": "macOS on Intel on a GitHub-hosted macos-15-intel runner", + "description": "The platform job of .github/workflows/compatibility.yml on a macos-15-intel x86-64 virtual machine with an x86-64 CPython 3.12.15 and Node 24.21.0, as a non-root user.", + "support": { + "status": "not-supported", + "lane": "macOS Intel", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15-intel)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "darwin", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-x64-cp315.json b/tests/compatibility/profiles/gha-macos-x64-cp315.json new file mode 100644 index 0000000..950fc90 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-x64-cp315.json @@ -0,0 +1,275 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-x64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted macos-15-intel runner", + "description": "The abi job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on macos-15-intel, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "macOS Intel", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15-intel)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-macos-x64-cp315t.json b/tests/compatibility/profiles/gha-macos-x64-cp315t.json new file mode 100644 index 0000000..aac82b9 --- /dev/null +++ b/tests/compatibility/profiles/gha-macos-x64-cp315t.json @@ -0,0 +1,275 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-macos-x64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted macos-15-intel runner", + "description": "The abi job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 (3.15t) from uv 0.13.0 on macos-15-intel, as the runner's user. The job installs the backend lock's native packages that have a wheel for this interpreter, binary-only and with the lock's hashes, imports and calls each once in a fresh process, and records the wheel tags, files and GIL state; thread safety is not tested. The lane table's statuses are for CPython 3.12 with the GIL, so the profile is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "macOS Intel", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "macOS", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: macos-15-intel)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "macos", + "provenance": "declared" + }, + "name": { + "value": "macos", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "names the macOS release the build targets; each run records it" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-ubi10-arm64-cp312.json b/tests/compatibility/profiles/gha-ubi10-arm64-cp312.json new file mode 100644 index 0000000..205b4a7 --- /dev/null +++ b/tests/compatibility/profiles/gha-ubi10-arm64-cp312.json @@ -0,0 +1,362 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-ubi10-arm64-cp312", + "title": "UBI 10 10.2 in rootless Podman on a GitHub-hosted ubuntu-24.04-arm runner", + "description": "The ubi job of .github/workflows/compatibility.yml: UBI 10 pinned by digest, in rootless Podman as the runner's user, all capabilities dropped, no-new-privileges, unprivileged, no device; the backend lock, Django check, Django tests and pytest with uv's CPython 3.12.15, not the image's Python. Not a RHEL host: the kernel, SELinux mode and systemd are the Ubuntu VM's. It copies the lane's status; a pass changes no status.", + "support": { + "status": "statically-resolvable", + "lane": "Linux aarch64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "ubi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04-arm), seen from the container", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + } + } + }, + { + "label": "UBI 10 container in rootless Podman, run as the runner's user", + "kind": { + "value": "container", + "provenance": "declared" + }, + "technology": { + "value": "podman", + "provenance": "declared" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "rhel", + "provenance": "declared" + }, + "version": { + "value": "10.2", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "not-applicable", + "reason": "a container shares the kernel of the layer below" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "systemd is not running as the service manager here; other managers are not probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "no service manager seen" + } + }, + "container": { + "engine_mode": { + "value": "rootless", + "provenance": "declared" + }, + "privileged": { + "value": false, + "provenance": "declared" + }, + "devices": { + "value": [], + "provenance": "declared" + }, + "orchestration": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-aarch64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-aarch64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-ubi10-x64-cp312.json b/tests/compatibility/profiles/gha-ubi10-x64-cp312.json new file mode 100644 index 0000000..9322085 --- /dev/null +++ b/tests/compatibility/profiles/gha-ubi10-x64-cp312.json @@ -0,0 +1,362 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-ubi10-x64-cp312", + "title": "UBI 10 10.2 in rootless Podman on a GitHub-hosted ubuntu-24.04 runner", + "description": "The ubi job of .github/workflows/compatibility.yml: UBI 10 pinned by digest, in rootless Podman as the runner's user, all capabilities dropped, no-new-privileges, unprivileged, no device; the backend lock, Django check, Django tests and pytest with uv's CPython 3.12.15, not the image's Python. Not a RHEL host: the kernel, SELinux mode and systemd are the Ubuntu VM's. Its glibc 2.39 and CPython 3.12.15 meet the checked bounds, so it copies checked.", + "support": { + "status": "checked", + "lane": "Linux x86-64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "ubi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04), seen from the container", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + } + } + }, + { + "label": "UBI 10 container in rootless Podman, run as the runner's user", + "kind": { + "value": "container", + "provenance": "declared" + }, + "technology": { + "value": "podman", + "provenance": "declared" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "rhel", + "provenance": "declared" + }, + "version": { + "value": "10.2", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "not-applicable", + "reason": "a container shares the kernel of the layer below" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "systemd is not running as the service manager here; other managers are not probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "no service manager seen" + } + }, + "container": { + "engine_mode": { + "value": "rootless", + "provenance": "declared" + }, + "privileged": { + "value": false, + "provenance": "declared" + }, + "devices": { + "value": [], + "provenance": "declared" + }, + "orchestration": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-x86_64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-x86_64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.39", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-ubi9-arm64-cp312.json b/tests/compatibility/profiles/gha-ubi9-arm64-cp312.json new file mode 100644 index 0000000..c73b187 --- /dev/null +++ b/tests/compatibility/profiles/gha-ubi9-arm64-cp312.json @@ -0,0 +1,362 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-ubi9-arm64-cp312", + "title": "UBI 9 9.8 in rootless Podman on a GitHub-hosted ubuntu-24.04-arm runner", + "description": "The ubi job of .github/workflows/compatibility.yml: UBI 9 pinned by digest, in rootless Podman as the runner's user, all capabilities dropped, no-new-privileges, unprivileged, no device; the backend lock, Django check, Django tests and pytest with uv's CPython 3.12.15, not the image's Python. Not a RHEL host: the kernel, SELinux mode and systemd are the Ubuntu VM's. It copies the lane's status; a pass changes no status.", + "support": { + "status": "statically-resolvable", + "lane": "Linux aarch64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "ubi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04-arm), seen from the container", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + } + } + }, + { + "label": "UBI 9 container in rootless Podman, run as the runner's user", + "kind": { + "value": "container", + "provenance": "declared" + }, + "technology": { + "value": "podman", + "provenance": "declared" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "rhel", + "provenance": "declared" + }, + "version": { + "value": "9.8", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "not-applicable", + "reason": "a container shares the kernel of the layer below" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "systemd is not running as the service manager here; other managers are not probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "no service manager seen" + } + }, + "container": { + "engine_mode": { + "value": "rootless", + "provenance": "declared" + }, + "privileged": { + "value": false, + "provenance": "declared" + }, + "devices": { + "value": [], + "provenance": "declared" + }, + "orchestration": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-aarch64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-aarch64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.34", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-ubi9-x64-cp312.json b/tests/compatibility/profiles/gha-ubi9-x64-cp312.json new file mode 100644 index 0000000..aebdc51 --- /dev/null +++ b/tests/compatibility/profiles/gha-ubi9-x64-cp312.json @@ -0,0 +1,362 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-ubi9-x64-cp312", + "title": "UBI 9 9.8 in rootless Podman on a GitHub-hosted ubuntu-24.04 runner", + "description": "The ubi job of .github/workflows/compatibility.yml: UBI 9 pinned by digest, in rootless Podman as the runner's user, all capabilities dropped, no-new-privileges, unprivileged, no device; the backend lock, Django check, Django tests and pytest with uv's CPython 3.12.15, not the image's Python. Not a RHEL host: the kernel, SELinux mode and systemd are the Ubuntu VM's. Its glibc 2.34 is below the checked bound of glibc 2.39, so it is not-assessed.", + "support": { + "status": "not-assessed", + "lane": "Linux x86-64 glibc", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "ubi" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Linux", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect", + "install", + "initialize", + "execute", + "correctness" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: ubuntu-24.04), seen from the container", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "the container sees its own os-release, not the VM's" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "not visible from the container" + } + } + }, + { + "label": "UBI 9 container in rootless Podman, run as the runner's user", + "kind": { + "value": "container", + "provenance": "declared" + }, + "technology": { + "value": "podman", + "provenance": "declared" + }, + "os": { + "family": { + "value": "linux", + "provenance": "declared" + }, + "name": { + "value": "rhel", + "provenance": "declared" + }, + "version": { + "value": "9.8", + "provenance": "declared" + }, + "kernel": { + "value": null, + "provenance": "not-applicable", + "reason": "a container shares the kernel of the layer below" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "systemd is not running as the service manager here; other managers are not probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "no service manager seen" + } + }, + "container": { + "engine_mode": { + "value": "rootless", + "provenance": "declared" + }, + "privileged": { + "value": false, + "provenance": "declared" + }, + "devices": { + "value": [], + "provenance": "declared" + }, + "orchestration": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + } + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": "cpython-312-x86_64-linux-gnu", + "provenance": "declared" + }, + "platform": { + "value": "linux-x86_64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": "glibc 2.34", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-arm64-cp312.json b/tests/compatibility/profiles/gha-windows-arm64-cp312.json new file mode 100644 index 0000000..688333c --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-arm64-cp312.json @@ -0,0 +1,270 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-arm64-cp312", + "title": "Windows on Arm on a GitHub-hosted windows-11-arm runner, native Arm64", + "description": "The platform-windows job of .github/workflows/compatibility.yml on a windows-11-arm virtual machine with a native Arm64 CPython 3.12.15 (win-arm64) and an arm64 Node 24.21.0. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the job checks the record with --no-policy under the temporary exception in docs/SECURITY-ARCHITECTURE.md; the record is no non-administrator evidence.", + "support": { + "status": "not-supported", + "lane": "Windows on Arm, native (`win_arm64`)", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-11-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-arm64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "win32", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-arm64-cp315.json b/tests/compatibility/profiles/gha-windows-arm64-cp315.json new file mode 100644 index 0000000..42f9467 --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-arm64-cp315.json @@ -0,0 +1,274 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-arm64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted windows-11-arm runner", + "description": "The abi-windows job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on windows-11-arm, which installs, imports and calls the lock's native packages that have a wheel here; thread safety is not tested. Not assessed: the lane table is for CPython 3.12. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the record is checked with --no-policy (docs/SECURITY-ARCHITECTURE.md).", + "support": { + "status": "not-assessed", + "lane": "Windows on Arm, native (`win_arm64`)", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-11-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-arm64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-arm64-cp315t.json b/tests/compatibility/profiles/gha-windows-arm64-cp315t.json new file mode 100644 index 0000000..9679490 --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-arm64-cp315t.json @@ -0,0 +1,274 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-arm64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted windows-11-arm runner", + "description": "The abi-windows job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 from uv 0.13.0 on windows-11-arm, which installs, imports and calls the lock's native packages that have a wheel here; thread safety is not tested. Not assessed: the lane table is for CPython 3.12. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the record is checked with --no-policy (docs/SECURITY-ARCHITECTURE.md).", + "support": { + "status": "not-assessed", + "lane": "Windows on Arm, native (`win_arm64`)", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-11-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-arm64", + "provenance": "declared" + }, + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-arm64-x64-cp312.json b/tests/compatibility/profiles/gha-windows-arm64-x64-cp312.json new file mode 100644 index 0000000..b3da547 --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-arm64-x64-cp312.json @@ -0,0 +1,270 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-arm64-x64-cp312", + "title": "x64 CPython emulated on a GitHub-hosted windows-11-arm runner", + "description": "The platform-windows job of .github/workflows/compatibility.yml on a windows-11-arm virtual machine with an x64 CPython 3.12.15 (win-amd64) and an x64 Node 24.21.0, run through Windows' x64 emulation; no lane in the lane table describes it, so the profile names none. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the record is checked with --no-policy (docs/SECURITY-ARCHITECTURE.md).", + "support": { + "status": "not-assessed", + "lane": null, + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "ARM64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-11-arm)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "aarch64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-amd64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "emulated", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "win32", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": "emulated", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-x64-cp312.json b/tests/compatibility/profiles/gha-windows-x64-cp312.json new file mode 100644 index 0000000..f5df451 --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-x64-cp312.json @@ -0,0 +1,270 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-x64-cp312", + "title": "Windows x64 on a GitHub-hosted windows-2025 runner", + "description": "The platform-windows job of .github/workflows/compatibility.yml on a windows-2025 x64 virtual machine with an x64 CPython 3.12.15 and Node 24.21.0. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the job checks the record with --no-policy under the temporary exception in docs/SECURITY-ARCHITECTURE.md; the record is no non-administrator evidence.", + "support": { + "status": "not-supported", + "lane": "Windows x64, native", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/compatibility.yml", + "job": "platform-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-2025)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.12.15", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-312", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-amd64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": "24.21.0", + "provenance": "declared" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "follows from the Node version; each run records it" + }, + "platform": { + "value": "win32", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-x64-cp315.json b/tests/compatibility/profiles/gha-windows-x64-cp315.json new file mode 100644 index 0000000..7407f07 --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-x64-cp315.json @@ -0,0 +1,274 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-x64-cp315", + "title": "CPython 3.15.0 with the GIL on a GitHub-hosted windows-2025 runner", + "description": "The abi-windows job of .github/workflows/python-abi.yml: CPython 3.15.0 with the GIL from uv 0.13.0 on windows-2025, which installs, imports and calls the lock's native packages that have a wheel here; thread safety is not tested. Not assessed: the lane table is for CPython 3.12. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the record is checked with --no-policy (docs/SECURITY-ARCHITECTURE.md).", + "support": { + "status": "not-assessed", + "lane": "Windows x64, native", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-2025)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-amd64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": false, + "provenance": "declared" + }, + "gil_enabled": { + "value": true, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +} diff --git a/tests/compatibility/profiles/gha-windows-x64-cp315t.json b/tests/compatibility/profiles/gha-windows-x64-cp315t.json new file mode 100644 index 0000000..1d64d3d --- /dev/null +++ b/tests/compatibility/profiles/gha-windows-x64-cp315t.json @@ -0,0 +1,274 @@ +{ + "schema_version": 1, + "kind": "profile", + "synthetic": false, + "id": "gha-windows-x64-cp315t", + "title": "Free-threaded CPython 3.15.0 (3.15t) on a GitHub-hosted windows-2025 runner", + "description": "The abi-windows job of .github/workflows/python-abi.yml: free-threaded CPython 3.15.0 from uv 0.13.0 on windows-2025, which installs, imports and calls the lock's native packages that have a wheel here; thread safety is not tested. Not assessed: the lane table is for CPython 3.12. It runs only for a pull request from a branch of this repository or a manual run on main. The runner's user holds an administrator token, so the record is checked with --no-policy (docs/SECURITY-ARCHITECTURE.md).", + "support": { + "status": "not-assessed", + "lane": "Windows x64, native", + "reference": "docs/DEPENDENCY-STRATEGY.md#platform-and-accelerator-lanes" + }, + "evidence_sources": [ + { + "workflow": ".github/workflows/python-abi.yml", + "job": "abi-windows" + } + ], + "runner": { + "runner_environment": { + "value": "github-hosted", + "provenance": "declared" + }, + "runner_os": { + "value": "Windows", + "provenance": "declared" + }, + "runner_arch": { + "value": "X64", + "provenance": "declared" + } + }, + "required_stages": [ + "detect" + ], + "environment": { + "layers": [ + { + "label": "Physical host, not visible to the runner", + "kind": { + "value": "host", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "not-applicable", + "reason": "the outermost host runs on hardware" + }, + "os": { + "family": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "cpu": { + "arch": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the physical hosts, and a VM guest cannot see them" + } + } + }, + { + "label": "GitHub-hosted runner virtual machine (runs-on: windows-2025)", + "kind": { + "value": "vm", + "provenance": "declared" + }, + "technology": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not document the hypervisor; each run records what the VM shows" + }, + "os": { + "family": { + "value": "windows", + "provenance": "declared" + }, + "name": { + "value": "windows", + "provenance": "declared" + }, + "version": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + }, + "kernel": { + "value": null, + "provenance": "unknown", + "reason": "changes with runner image updates; each run records it" + } + }, + "cpu": { + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "whether the hypervisor emulates the CPU is not visible from inside the VM" + } + }, + "service_manager": { + "name": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + }, + "scope": { + "value": null, + "provenance": "unknown", + "reason": "only systemd is probed" + } + } + } + ] + }, + "workload": { + "runtimes": { + "python": { + "implementation": { + "value": "cpython", + "provenance": "declared" + }, + "version": { + "value": "3.15.0", + "provenance": "declared" + }, + "cache_tag": { + "value": "cpython-315", + "provenance": "declared" + }, + "soabi": { + "value": null, + "provenance": "unknown", + "reason": "each run records it" + }, + "platform": { + "value": "win-amd64", + "provenance": "declared" + }, + "arch": { + "value": "x86_64", + "provenance": "declared" + }, + "pointer_bits": { + "value": 64, + "provenance": "declared" + }, + "free_threaded_build": { + "value": true, + "provenance": "declared" + }, + "gil_enabled": { + "value": false, + "provenance": "declared" + }, + "libc": { + "value": null, + "provenance": "unknown", + "reason": "only glibc is recorded" + }, + "execution": { + "value": "native", + "provenance": "declared" + } + }, + "node": { + "version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "modules_abi": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "napi_version": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "platform": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "arch": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + }, + "execution": { + "value": null, + "provenance": "unknown", + "reason": "this job records no Node (--no-node)" + } + } + } + }, + "compute": { + "backends": [ + { + "id": "cpu", + "kind": "cpu", + "api": "cpu", + "available": { + "value": true, + "provenance": "declared" + }, + "device": { + "value": null, + "provenance": "unknown", + "reason": "GitHub does not fix the CPU model of hosted runners" + }, + "version": { + "value": null, + "provenance": "not-applicable", + "reason": "a CPU has no backend version" + }, + "features": { + "value": null, + "provenance": "unknown", + "reason": "not declared" + } + } + ], + "accelerator_inventory": { + "value": null, + "provenance": "not-applicable", + "reason": "this lane uses the CPU only" + } + }, + "policy": { + "rules": {}, + "exceptions": [] + } +}