diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ddd0f3497..edc143c0b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -473,7 +473,7 @@ jobs: echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT" # Anaconda evidence, same shape as PyPI's above but for the ``conda`` - # matrix (2 OS x the supported tier read above). Unlike PyPI's, this + # matrix (linux-64 and osx-64, each Python read above). Unlike PyPI's, this # endpoint reported ``cf-cache-status: DYNAMIC`` on every request checked # while writing this, i.e. not cached -- so there is no staleness direction to # reason about here. This is the job-level cost saver mirroring ``pypi``'s; @@ -488,7 +488,9 @@ jobs: run: | set -euo pipefail - # Each of ``conda``'s two platforms, for each supported version. + # linux-64 and osx-64, for each supported version. osx-arm64 is + # published too and not counted, as it was not when ``macos-latest`` + # uploaded it beside the osx-64 package it converted (#1822). read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" expected=$(( 2 * ${#builds[@]} )) # Same coupling risk, and the same fix, as the PyPI check above. @@ -1216,8 +1218,15 @@ jobs: strategy: fail-fast: false matrix: + # #1822: each leg builds and publishes its own runner's platform only, + # since ``conda convert`` copies binaries unchanged. ``macos-latest`` is + # arm64, so osx-arm64, and ``macos-26-intel`` is Intel, so osx-64, per + # https://docs.github.com/en/actions/reference/runners/github-hosted-runners + # (GA in actions/runner-images#13739). The osx-64 leg ends with GitHub's + # Intel images, which actions/runner-images#13045 says will end. os: - macos-latest + - macos-26-intel - ubuntu-latest # - windows-latest # skip for now # #1633: the supported tier only, from .github/python-versions.toml @@ -1267,6 +1276,9 @@ jobs: case "${{ matrix.os }}" in macos-latest) + target_platform="osx-arm64" + ;; + macos-26-intel) target_platform="osx-64" ;; ubuntu-latest) @@ -1407,6 +1419,9 @@ jobs: case "${{ matrix.os }}" in macos-latest) + target_platform="osx-arm64" + ;; + macos-26-intel) target_platform="osx-64" ;; ubuntu-latest) @@ -1420,9 +1435,11 @@ jobs: ;; esac + # #1822: never ``conda convert`` across architectures; see the matrix. host_platform="$(basename "$(dirname "$host_package")")" - if [ -n "$target_platform" ] && [ "$target_platform" != "$host_platform" ]; then - conda convert "$host_package" -o "$out_dir" -p "$target_platform" + if [ "$host_platform" != "$target_platform" ]; then + echo "::error title=${host_platform} package on a ${target_platform:-unmapped} leg (#1822)::${{ matrix.os }} publishes ${target_platform:-nothing}, and conda built ${host_platform} here; a package is published only for the platform it was built on." + exit 1 fi package_paths="$(find "$out_dir" -type f \( -name '*.tar.bz2' -o -name '*.conda' \) -print | sort)" diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 1cfa231b0..7e0052eba 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -424,8 +424,11 @@ jobs: strategy: fail-fast: false matrix: + # #1822: each leg builds and publishes its own runner's platform only, + # as create-release.yml's `conda` job says. os: - macos-latest + - macos-26-intel - ubuntu-latest # - windows-latest # skip for now # #1633: the supported tier, from .github/python-versions.toml through @@ -512,6 +515,9 @@ jobs: case "${{ matrix.os }}" in macos-latest) + target_platform="osx-arm64" + ;; + macos-26-intel) target_platform="osx-64" ;; ubuntu-latest) @@ -527,9 +533,13 @@ jobs: echo "host_package=$host_package" >> "$GITHUB_OUTPUT" + # #1822: never `conda convert` across architectures. It relabels the + # package and copies its binaries unchanged, so the osx-64 package it + # made from the arm64 build carried arm64 extensions. host_platform="$(basename "$(dirname "$host_package")")" - if [ -n "$target_platform" ] && [ "$target_platform" != "$host_platform" ]; then - conda convert "$host_package" -o "$out_dir" -p "$target_platform" + if [ "$host_platform" != "$target_platform" ]; then + echo "::error title=${host_platform} package on a ${target_platform:-unmapped} leg (#1822)::${{ matrix.os }} publishes ${target_platform:-nothing}, and conda built ${host_platform} here; a package is published only for the platform it was built on." + exit 1 fi package_paths="$(find "$out_dir" -type f \( -name '*.tar.bz2' -o -name '*.conda' \) -print | sort)" @@ -552,9 +562,11 @@ jobs: # modules alone. This unpacks the package built for this runner and # checks that every vendored distribution has every file its RECORD lists # and imports from there, with no site-packages on the path, before - # anything is uploaded. setup-python's interpreter, not conda's: it is - # the matrix version the package was built for, which conda's base is - # not on 3.15. + # anything is uploaded. #1822: it also fails unless the package, and every + # native binary in it, is for this runner's platform, so a relabelled + # package cannot pass on the runner that built it. setup-python's + # interpreter, not conda's: it is the matrix version the package was + # built for, which conda's base is not on 3.15. - name: Smoke-test the vendored packages env: PCAPKIT_CONDA_PACKAGE: ${{ steps.conda_build.outputs.host_package }} diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 01b43a608..eb85a0c38 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -50,12 +50,12 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``tests/project/test_bump_version.py``'s ``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version`` (``:494-514``) asserts the two agree, and ``create-release.yml``'s -``unit-tests`` job (``:579-595``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:581-597``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1776-1846``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every -publishing job requires it to have succeeded (``:619,806,972,1208``), so a +publishing job requires it to have succeeded (``:621,808,974,1210``), so a citation left behind fails the gate well before any approval is requested. Either run the script, or move both fields by hand in the same commit. @@ -66,7 +66,7 @@ process: the ``changelog`` job (``unit-tests.yml:1624-1643``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" -(``create-release.yml:675-676``). Both enforce what the changelog convention +(``create-release.yml:677-678``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -83,8 +83,8 @@ neither is a workaround for the other: the commit the pushed tag points at, and reads ``pcapkit.__version__`` out of *that* tree (``:327``). Every downstream tag and release name is built from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}`` - is ``github``'s ``tag_name`` at ``:717`` and the release ``pypi`` and - ``conda`` upload to at ``:1115`` and ``:1462``, always from + is ``github``'s ``tag_name`` at ``:719`` and the release ``pypi`` and + ``conda`` upload to at ``:1117`` and ``:1479``, always from ``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version actually released is whatever ``__version__`` says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag @@ -144,26 +144,26 @@ days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and the default branch, and ``tag`` pulled ``main`` before tagging. * On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes - ``target_commitish`` ``github.sha`` (``:730``); on a ``v*`` push the tag + ``target_commitish`` ``github.sha`` (``:732``); on a ``v*`` push the tag already exists and it passes none. Either way it then checks that the tag names that commit. -* ``tag`` cuts ``conda-+0`` at that commit (``:844-871``) before it +* ``tag`` cuts ``conda-+0`` at that commit (``:846-873``) before it reads ``main`` at all; the :file:`conda/build` reset is a separate commit on - top of ``main`` (``:893-946``), described under `Precautions`_. + top of ``main`` (``:895-948``), described under `Precautions`_. * ``pypi`` and ``conda`` check out that commit and, before building, check that every tag they publish under names it, as ``tag`` does for the ``v*`` tag. A run created for a *later* commit of the same version therefore never publishes a second tree under that version. ``version_check`` resolves the - ``v*`` tag the same way first (``:534-552``), and off a ``v*`` ref a tag + ``v*`` tag the same way first (``:536-554``), and off a ``v*`` ref a tag naming another commit skips the gate and all four publishers (`#1748 `__): ``Release status`` posts a ``::notice`` if that release is complete, or a - ``::warning`` naming both commits and what is missing (``:1607-1617``), and + ``::warning`` naming both commits and what is missing (``:1624-1634``), and the run stays green. A ``v*`` push of such a version still fails with the check's ``::error``. A partial release is finished by re-running the run that tagged it. * ``pypi`` and ``conda`` attach their files with ``gh release upload`` - (``:1108-1145``, ``:1457-1492``), not ``action-gh-release``, which calls Update + (``:1110-1147``, ``:1474-1509``), not ``action-gh-release``, which calls Update a release before every upload (3.0.2's ``src/github.ts:631-683``). A file the release already has is left alone (`#1640 `__): the action @@ -257,11 +257,11 @@ The owner's choice on `#1634 the release with a GitHub App installation token, which Create a release accepts for a target that changes a workflow file when it has the "Workflows" repository permission (write). "Mint the release App's token" -(``create-release.yml:690-698``) runs +(``create-release.yml:692-700``) runs ``actions/create-github-app-token@v3`` with ``permission-contents: write`` and ``permission-workflows: write`` and no ``owner`` or ``repositories``, so the token is "scoped to only the current repository" and revoked in the action's -``post`` step (v3.3.0's README). Only Create Release spends it (``:734``). +``post`` step (v3.3.0's README). Only Create Release spends it (``:736``). While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the workflow keeps working, refusals included, until the App exists. A PAT would @@ -291,13 +291,13 @@ the release test gate, ask for a second approval and race the first run's uploads from another concurrency group, so ``version_check`` skips a ``push`` whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:290-294``), and ``release_status`` reports it with ``::notice title=Nothing to release`` -(``:1569-1571``). A tag pushed by a person runs as before. Whether a tag made +(``:1586-1588``). A tag pushed by a person runs as before. Whether a tag made through the Releases API fires ``push``, and with the bot as actor, is unmeasured; if not, the guard never matches and the cost is one approval to reject. A refused Create Release (404, or 403 "Resource not accessible by -integration") is explained by "Explain a refused release tag" (``:767-779``) +integration") is explained by "Explain a refused release tag" (``:769-781``) according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; with the App's, it points at the App's permissions and installation. For a final release, first delete any draft ``v`` the refused run left: @@ -336,9 +336,11 @@ checks *is* the one it is about to create: KTLO legs' wheels (`#1633 `__) are not counted. This job is already idempotent (``skip-existing: true`` on both upload steps), so the check exists only to save the cost of the build matrix, not for correctness. -* ``conda`` compares Anaconda's aggregate count against the expected one (a - file per OS and Python version of its matrix) as the same kind of job-level - cost saver, and *also* +* ``conda`` compares Anaconda's aggregate count against the expected one (the + ``linux-64`` and ``osx-64`` files for each Python version of its matrix; the + ``osx-arm64`` leg's are published but not counted, + `#1822 `__) as the same + kind of job-level cost saver, and *also* checks, per matrix leg, whether that leg's own platform/Python distribution is already there -- this second check is the actual correctness fix, because ``anaconda/actions/upload-package`` has no ``skip-existing`` equivalent, only @@ -423,7 +425,7 @@ The Release Pipeline GH["github -- GitHub Release
creates/attaches the v* tag
THE ONLY APPROVAL"] GH --> TG["tag -- Conda Tag
conda-<version>+0 tag at github.sha
then conda/build reset on main"] GH --> PY["pypi -- build + publish
OIDC trusted publishing"] - TG --> CD["conda -- build + upload
matrix: 2 OS, per-leg evidence"] + TG --> CD["conda -- build + upload
matrix: OS x Python, per-leg evidence"] GH --> CD VC --> RS UT --> RS @@ -447,8 +449,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``, removed on `#887 `__. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github`` -(``.github/workflows/create-release.yml:806,972``), and ``conda`` depends on -``tag`` and ``github`` (``:1208``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:808,974``), and ``conda`` depends on +``tag`` and ``github`` (``:1210``) -- so nothing downstream of ``github`` can start before it is approved, and rejecting it leaves nothing tagged and nothing published. Before that removal ``tag`` depended on ``version_check`` alone, so removing its reviewer without moving this dependency would have let @@ -495,7 +497,7 @@ back, and neither is needed, since a plain re-run self-heals; see ``environment: pypi`` **stays even though its reviewer is gone.** ``pypi`` publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at -``:967``, ``id-token: write`` at ``:971``), and PyPI's trusted-publisher +``:969``, ``id-token: write`` at ``:973``), and PyPI's trusted-publisher configuration can be scoped to a GitHub Actions environment name. If this project's publisher on PyPI is scoped that way, removing the ``pypi`` name -- not just its reviewer -- would break the upload with a claim mismatch; @@ -506,7 +508,7 @@ the name regardless, since there is no upside to removing it. ``conda-tag`` **writes to** ``main``\ **, so a release is not read-only on the branch.** After cutting ``conda-+0`` at the run's commit, the ``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of -``main`` (``.github/workflows/create-release.yml:893-946``) -- a plain +``main`` (``.github/workflows/create-release.yml:895-948``) -- a plain fast-forward push, never a rewrite -- so the next Conda Update bump of that version is ``+1``. There is none when the run's commit is not on ``main``, nor when ``main``'s tip already says ``0`` (the usual case, since diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index a01b67095..c127802f3 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -303,7 +303,7 @@ the caller's own workflow run, as one of the caller's own jobs, rather than starting an independent run after the caller finishes. All four calls in this repository target the same reusable workflow, with the same input: -* ``.github/workflows/create-release.yml:593`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:595`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. * ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. @@ -366,13 +366,13 @@ its ``verdict`` job found the Unit Tests verdict was for another commit -- which the ``vendor_update`` job reads from that run's jobs first, as run 38650191283 was. ``!cancelled()`` keeps a skipped ``vendor_update``, on a push, from skipping it. ``unit-tests`` then depends on that job with -``needs: [ version_check ]`` (``create-release.yml:581``) and an ``if:`` that +``needs: [ version_check ]`` (``create-release.yml:583``) and an ``if:`` that calls no status-check function, so a skipped dependency skips it too -- and every job past it is gated the same way: ``github`` needs ``[ unit-tests, -version_check ]`` (``:619``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:806``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:972``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:1208``). +version_check ]`` (``:621``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:808``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:974``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:1210``). .. mermaid:: @@ -398,7 +398,7 @@ unit-tests, version_check ]`` (``:1208``). This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and ``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi`` -(``create-release.yml:806,972``) and of ``conda`` (``:1208``), but ``github`` +(``create-release.yml:808,974``) and of ``conda`` (``:1210``), but ``github`` already implies those edges, since it depends on both itself. Drawing them would add lines without changing which job can start before which, or which job a skip in ``version_check`` reaches. @@ -916,10 +916,10 @@ Deployment ``environment:`` Five jobs declare a deployment ``environment:`` and can therefore pause for an approval, four in ``create-release.yml`` and one in ``cron-python-versions.yml``: -* ``github`` -- ``environment: github-release`` (``create-release.yml:616``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:804``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:967``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1205``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:618``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:806``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:969``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1207``) * ``pr`` -- ``environment: github-release``, for the release App's key (see `Python Versions`_) diff --git a/tests/project/test_conda_native_platforms_1822.py b/tests/project/test_conda_native_platforms_1822.py new file mode 100644 index 000000000..df27468bc --- /dev/null +++ b/tests/project/test_conda_native_platforms_1822.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +"""Each conda package carries binaries for its own platform (#1822). + +* Both conda jobs map each runner label of their ``os`` axis to the conda + platform of that runner's own architecture, as GitHub's runner-images table + gives it (the snapshot in :file:`python_versions_fixtures`), and between them + publish exactly :data:`conda_vendor.PLATFORMS`. +* Neither job runs ``conda convert``. Run as the job runs it, with a stand-in + ``conda``, a leg publishes the one package conda built, and fails before any + upload when that is not the platform its label maps to. +* ``--smoke`` reads each native binary's ELF or Mach-O header and fails on one + that is not for the package's platform, and on a package that is not for the + runner's own. + +""" + +from __future__ import annotations + +import bz2 +import importlib.util +import io +import json +import os +import pathlib +import re +import shutil +import stat +import subprocess # nosec: B404 +import sys +import tarfile +import tempfile +import textwrap +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +try: + import tomllib +except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / '.github' / 'workflows' +FIXTURES = pathlib.Path(__file__).resolve().parent / 'python_versions_fixtures' +NO_TOMLLIB = 'tomllib, which util/conda_vendor.py imports, needs Python 3.11+' +BASH = shutil.which('bash') + +#: The conda jobs, as (workflow, job). +JOBS = (('cron-conda.yml', 'conda-dist'), ('create-release.yml', 'conda')) +#: What each leg publishes. ``macos-latest`` is arm64 and ``macos-26-intel`` +#: Intel, per https://docs.github.com/en/actions/reference/runners/github-hosted-runners +#: as read on 2026-10-16; the test below checks the snapshot of the image table too. +LEGS = {'macos-latest': 'osx-arm64', 'macos-26-intel': 'osx-64', 'ubuntu-latest': 'linux-64'} +#: ``platform.machine()``, as the runner-images table names the architecture. +ARCH = {'x86_64': 'x64', 'arm64': 'arm64'} + +_MODULES = {} # type: dict[str, Any] + + +def load(name: 'str') -> 'Any': + """:file:`util/.py`, loaded once; skipped without :mod:`tomllib`, which both import.""" + if tomllib is None: # pragma: no cover + raise unittest.SkipTest(NO_TOMLLIB) + if name not in _MODULES: + spec = importlib.util.spec_from_file_location(name, ROOT / 'util' / f'{name}.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + _MODULES[name] = module + return _MODULES[name] + + +def job(name: 'str', job_id: 'str') -> 'dict[str, Any]': + if yaml is None: # pragma: no cover + raise unittest.SkipTest('PyYAML, from the test extra, is not installed') + return yaml.safe_load((WORKFLOWS / name).read_text(encoding='utf-8'))['jobs'][job_id] + + +#: A ``case "${{ matrix.os }}" in`` arm setting ``target_platform``. +ARM = re.compile(r'^\s*([\w.-]+)\)\s*\n\s*target_platform="([^"]*)"', re.MULTILINE) + + +def mappings(spec: 'dict[str, Any]') -> 'list[dict[str, str]]': + """Each ``case "${{ matrix.os }}"`` of the job's steps, as label to platform.""" + found = [] + for step in spec.get('steps', []): + for block in re.findall(r'case "\$\{\{ matrix\.os \}\}" in\n(.*?)\n\s*esac', str(step.get('run', '')), + re.DOTALL): + found.append(dict(ARM.findall(block))) + return found + + +class RunnerMappingTests(unittest.TestCase): + """Each label publishes the platform its runner is.""" + + def test_each_leg_publishes_its_runners_own_platform(self) -> None: + vendor, updater = load('conda_vendor'), load('update_python_versions') + images = updater.parse_images((FIXTURES / 'runner-images-readme.md').read_text(encoding='utf-8')) + for name, job_id in JOBS: + spec = job(name, job_id) + labels = spec['strategy']['matrix']['os'] + blocks = mappings(spec) + self.assertTrue(blocks, f'{name} {job_id} maps no runner label') + for block in blocks: + with self.subTest(workflow=name, job=job_id): + self.assertEqual({label: block.get(label) for label in labels}, LEGS) + for label in labels: + sys_platform, _, machine = vendor.PLATFORMS[block[label]] + with self.subTest(workflow=name, job=job_id, label=label): + self.assertEqual({(target.platform, target.arch) + for target in updater.label_targets(label, images)}, + {(sys_platform, ARCH[machine])}) + + def test_the_legs_are_the_vendored_platforms(self) -> None: + """What conda/requirements.txt is derived for is what the legs build.""" + self.assertEqual(sorted(load('conda_vendor').PLATFORMS), sorted(LEGS.values())) + self.assertEqual(sorted(load('conda_vendor').BINARIES), sorted(LEGS.values())) + + +#: Stands in for ``conda``: ``build`` writes an empty package under +#: ``$FAKE_SUBDIR`` of ``--output-folder``, ``convert`` one under ``-p`` of ``-o``. +CONDA = textwrap.dedent('''\ + #!/usr/bin/env bash + set -eu + command="$1"; shift + out=""; platform="${FAKE_SUBDIR}" + while [ "$#" -gt 0 ]; do + case "$1" in + --output-folder|-o) out="$2"; shift ;; + -p) platform="$2"; shift ;; + esac + shift + done + mkdir -p "$out/$platform" + : > "$out/$platform/pypcapkit-1.0-py314_0.tar.bz2" +''') + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class NoConvertTests(unittest.TestCase): + """No package is relabelled for another architecture.""" + + def test_no_workflow_runs_conda_convert(self) -> None: + if yaml is None: # pragma: no cover + raise unittest.SkipTest('PyYAML, from the test extra, is not installed') + for path in sorted(WORKFLOWS.glob('*.yml')): + for job_id, spec in (yaml.safe_load(path.read_text(encoding='utf-8')).get('jobs') or {}).items(): + for step in spec.get('steps', []): + code = [line for line in str(step.get('run', '')).splitlines() + if not line.lstrip().startswith('#')] + with self.subTest(workflow=path.name, job=job_id, step=step.get('name')): + self.assertFalse([line for line in code if re.search(r'\bconda\s+convert\b', line)]) + + def build(self, name: 'str', job_id: 'str', label: 'str', built: 'str') -> 'tuple[int, str, list[str]]': + """The job's build step on ``label`` where conda builds ``built``: status, log, packages.""" + step = next(step for step in job(name, job_id)['steps'] if step.get('id') == 'conda_build') + script = step['run'].replace('${{ matrix.os }}', label).replace('${{ matrix.python-version }}', '3.14') + self.assertNotIn('${{', script) + with tempfile.TemporaryDirectory() as temp: + scratch = pathlib.Path(temp) + (scratch / 'bin').mkdir() + conda = scratch / 'bin' / 'conda' + conda.write_text(CONDA, encoding='utf-8') + conda.chmod(conda.stat().st_mode | stat.S_IXUSR) + output = scratch / 'output' + output.write_text('', encoding='utf-8') + result = subprocess.run( + [BASH, '-e', '-c', script], cwd=temp, # nosec: B603 + env=dict(os.environ, PATH=f'{scratch / "bin"}{os.pathsep}{os.environ["PATH"]}', + TMPDIR=temp, GITHUB_OUTPUT=str(output), FAKE_SUBDIR=built, + PCAPKIT_VERSION='1.0', PCAPKIT_BUILD='0'), + capture_output=True, text=True, check=False, timeout=60) + written = output.read_text(encoding='utf-8') + packages = re.search(r'^packages< None: + for name, job_id in JOBS: + for label, platform in LEGS.items(): + with self.subTest(workflow=name, label=label): + status, log, packages = self.build(name, job_id, label, platform) + self.assertEqual(status, 0, log) + self.assertEqual(packages, [platform]) + + def test_a_leg_whose_conda_built_another_platform_fails(self) -> None: + for name, job_id in JOBS: + for label, built in (('macos-26-intel', 'osx-arm64'), ('macos-latest', 'osx-64'), + ('macos-99', 'osx-arm64')): + with self.subTest(workflow=name, label=label): + status, log, packages = self.build(name, job_id, label, built) + self.assertEqual(status, 1, log) + self.assertIn(f'::error title={built} package on a ', log) + self.assertEqual(packages, []) + + +def elf(machine: 'int', bits: 'int' = 64) -> 'bytes': + """A little-endian ELF shared object's header for ``e_machine``.""" + return b'\x7fELF' + bytes((bits // 32, 1, 1, 0, 0)) + bytes(7) + (3).to_bytes(2, 'little') \ + + machine.to_bytes(2, 'little') + bytes(44) + + +def macho(cpu: 'int') -> 'bytes': + """A 64-bit little-endian Mach-O bundle's header for ``cputype``.""" + return b'\xcf\xfa\xed\xfe' + cpu.to_bytes(4, 'little') + bytes(4) + (8).to_bytes(4, 'little') + bytes(16) + + +def fat(*cpus: 'int') -> 'bytes': + """A universal Mach-O's header with a slice per ``cputype``.""" + return b'\xca\xfe\xba\xbe' + len(cpus).to_bytes(4, 'big') + b''.join( + cpu.to_bytes(4, 'big') + bytes(16) for cpu in cpus) + bytes(64) + + +X86_64, ARM64 = 0x01000007, 0x0100000c +#: One of each binary a wheel may carry, with what :func:`conda_vendor.native` reads. +STUBS = { + 'elf-x86_64': (elf(62), ('ELF', {'x86_64'})), + 'elf-arm64': (elf(183), ('ELF', {'arm64'})), + 'elf-i386': (elf(3, bits=32), ('ELF', {'i386'})), + 'macho-x86_64': (macho(X86_64), ('Mach-O', {'x86_64'})), + 'macho-arm64': (macho(ARM64), ('Mach-O', {'arm64'})), + 'universal2': (fat(X86_64, ARM64), ('Mach-O', {'x86_64', 'arm64'})), + 'fat-arm64': (fat(ARM64), ('Mach-O', {'arm64'})), +} +#: The stubs a package for each platform may carry. +ACCEPTS = { + 'linux-64': {'elf-x86_64'}, + 'osx-64': {'macho-x86_64', 'universal2'}, + 'osx-arm64': {'macho-arm64', 'universal2', 'fat-arm64'}, +} + + +class ArchitectureTests(unittest.TestCase): + """:func:`conda_vendor.foreign` against stub binaries of each architecture.""" + + def test_native_reads_each_header(self) -> None: + vendor = load('conda_vendor') + for stub, (head, (kind, machines)) in STUBS.items(): + with self.subTest(stub=stub): + self.assertEqual(vendor.native(head), (kind, frozenset(machines))) + java = b'\xca\xfe\xba\xbe\x00\x00\x00\x41' + bytes(32) + for head in (java, b'', b'\x7fEL', b'# -*- coding: utf-8 -*-\n', b'MZ\x90\x00'): + with self.subTest(head=head[:8]): + self.assertIsNone(vendor.native(head)) + + def test_each_platform_takes_only_its_own_machine(self) -> None: + vendor = load('conda_vendor') + self.assertEqual(sorted(ACCEPTS), sorted(vendor.PLATFORMS)) + for subdir, accepted in ACCEPTS.items(): + for stub, (head, _) in STUBS.items(): + with self.subTest(subdir=subdir, stub=stub), tempfile.TemporaryDirectory() as temp: + path = pathlib.Path(temp) / 'lib' / 'pcapkit' / '_extern' / 'native' / '_speedups.so' + path.parent.mkdir(parents=True) + path.write_bytes(head) + count, problems = vendor.foreign(pathlib.Path(temp), subdir) + self.assertEqual(count, 1) + if stub in accepted: + self.assertEqual(problems, []) + else: + self.assertEqual(len(problems), 1, problems) + self.assertTrue(problems[0].startswith('lib/pcapkit/_extern/native/_speedups.so: '), + problems) + self.assertIn(f'as {subdir} needs', problems[0]) + + def test_other_files_are_passed_over_and_an_unreadable_library_is_not(self) -> None: + vendor = load('conda_vendor') + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + (root / 'pkg').mkdir() + (root / 'pkg' / 'Main.class').write_bytes(b'\xca\xfe\xba\xbe\x00\x00\x00\x41' + bytes(32)) + (root / 'pkg' / '__init__.py').write_text('', encoding='utf-8') + (root / 'pkg' / 'data.json').write_text('{}', encoding='utf-8') + self.assertEqual(vendor.foreign(root, 'linux-64'), (0, [])) + (root / 'pkg' / '_native.pyd').write_bytes(b'MZ\x90\x00' + bytes(60)) + (root / 'pkg' / 'libffi.so.8').write_text('not a library', encoding='utf-8') + count, problems = vendor.foreign(root, 'linux-64') + self.assertEqual(count, 0) + self.assertEqual(problems, ['pkg/_native.pyd: neither ELF nor Mach-O, so its machine is unknown', + 'pkg/libffi.so.8: neither ELF nor Mach-O, so its machine is unknown']) + + def test_noarch_carries_no_binary(self) -> None: + vendor = load('conda_vendor') + with tempfile.TemporaryDirectory() as temp: + (pathlib.Path(temp) / 'x.so').write_bytes(STUBS['elf-x86_64'][0]) + self.assertEqual(vendor.foreign(pathlib.Path(temp), 'noarch'), + (1, ['x.so: ELF x86_64, but noarch has no native machine'])) + + def test_the_runner_is_read_from_its_system_and_machine(self) -> None: + vendor = load('conda_vendor') + for here, subdir in ((('Linux', 'x86_64'), 'linux-64'), (('Darwin', 'x86_64'), 'osx-64'), + (('Darwin', 'arm64'), 'osx-arm64'), (('Linux', 'aarch64'), None), + (('Windows', 'AMD64'), None)): + with self.subTest(here=here), mock.patch.object(vendor.platform, 'system', return_value=here[0]), \ + mock.patch.object(vendor.platform, 'machine', return_value=here[1]): + self.assertEqual(vendor.runner_platform(), subdir) + + +#: A ``pcapkit/__init__.py`` reduced to its ``_extern`` shim. +SHIM = textwrap.dedent('''\ + import os, sys + _extern = os.path.join(os.path.dirname(os.path.realpath(__file__)), '_extern') + if os.path.exists(_extern): + sys.path.append(_extern) +''') +#: A vendored distribution's native library, which nothing imports. +NATIVE = 'pcapkit/_extern/native/_speedups.so' +#: The rest of that distribution, so that the probe's checks pass. +DISTRIBUTION = { + 'pcapkit/_extern/native/__init__.py': b'', + 'pcapkit/_extern/native-1.0.dist-info/METADATA': b'Name: native\n', + 'pcapkit/_extern/native-1.0.dist-info/RECORD': + b'native/__init__.py,,\nnative/_speedups.so,,\nnative-1.0.dist-info/METADATA,,\n' + b'native-1.0.dist-info/RECORD,,\n', +} + + +class SmokeArchitectureTests(unittest.TestCase): + """``--smoke`` on a package whose platform or binaries are not this runner's.""" + + def setUp(self) -> None: + self.runner = load('conda_vendor').runner_platform() + if self.runner is None: # pragma: no cover + raise unittest.SkipTest('this machine is none of the platforms the conda legs build on') + self.own = next(head for stub, (head, _) in STUBS.items() if stub in ACCEPTS[self.runner]) + self.other = next(head for stub, (head, _) in STUBS.items() + if stub not in ACCEPTS[self.runner] and stub.startswith('macho-')) + + def smoke(self, subdir: 'Optional[str]', files: 'dict[str, bytes]') -> 'tuple[int, str]': + site = f'lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/' + files = dict({'pcapkit/__init__.py': SHIM.encode()}, **DISTRIBUTION, **files) + members = {site + name: data for name, data in files.items()} + if subdir is not None: + members['info/index.json'] = json.dumps({'name': 'pypcapkit', 'subdir': subdir}).encode() + with tempfile.TemporaryDirectory() as temp: + package = pathlib.Path(temp) / 'pypcapkit-0-py_0.tar.bz2' + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode='w') as archive: + for name, data in members.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + package.write_bytes(bz2.compress(buffer.getvalue())) + result = subprocess.run( # nosec: B603 + [sys.executable, str(ROOT / 'util' / 'conda_vendor.py'), '--smoke', str(package)], + capture_output=True, text=True, check=False, timeout=120) + return result.returncode, result.stdout + result.stderr + + def test_the_runners_own_binary_passes(self) -> None: + status, log = self.smoke(self.runner, {NATIVE: self.own}) + self.assertEqual(status, 0, log) + self.assertIn(f'{self.runner}: 1 native binaries read, none failing', log) + self.assertIn('native-1.0.dist-info: 4/4 files, ok', log) + + def test_a_relabelled_binary_fails(self) -> None: + """What ``conda convert`` made: the label is this runner's, the binary another machine's.""" + status, log = self.smoke(self.runner, {NATIVE: self.other}) + self.assertEqual(status, 1, log) + self.assertIn(f'FOREIGN lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/' + f'{NATIVE}: Mach-O ', log) + self.assertIn(f'as {self.runner} needs', log) + + def test_a_package_for_another_platform_fails(self) -> None: + other = next(subdir for subdir in ACCEPTS if subdir != self.runner) + status, log = self.smoke(other, {NATIVE: self.own}) + self.assertEqual(status, 1, log) + self.assertIn(f'is filed under {other}, and this runner is {self.runner}', log) + + def test_a_package_naming_no_platform_fails(self) -> None: + status, log = self.smoke(None, {NATIVE: self.own}) + self.assertEqual(status, 1, log) + self.assertIn('info/index.json names no subdir', log) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_conda_vendor_1814.py b/tests/project/test_conda_vendor_1814.py index f60e1549e..a56664d72 100644 --- a/tests/project/test_conda_vendor_1814.py +++ b/tests/project/test_conda_vendor_1814.py @@ -13,7 +13,8 @@ * The committed list holds every requirement :file:`pyproject.toml` makes live on a conda build, with its marker, and none that is dead there. * ``--smoke`` passes on a conda package whose vendored distributions are complete - and fails on one missing a data file. + and fails on one missing a data file. Its platform checks (#1822) are + :file:`test_conda_native_platforms_1822.py`'s. """ @@ -276,11 +277,17 @@ class SmokeTests(unittest.TestCase): def package(self, temp: 'str', files: 'dict[str, str]') -> 'pathlib.Path': site = f'lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/' path = pathlib.Path(temp) / 'pypcapkit-0-py_0.tar.bz2' + # #1822: filed under this runner's platform, which --smoke requires. + runner = vendor().runner_platform() + if runner is None: # pragma: no cover + raise unittest.SkipTest('this machine is none of the platforms the conda legs build on') + members = {site + name: text for name, text in dict({'pcapkit/__init__.py': SHIM}, **files).items()} + members['info/index.json'] = json.dumps({'name': 'pypcapkit', 'subdir': runner}) buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode='w') as archive: - for name, text in dict({'pcapkit/__init__.py': SHIM}, **files).items(): + for name, text in members.items(): data = text.encode('utf-8') - info = tarfile.TarInfo(site + name) + info = tarfile.TarInfo(name) info.size = len(data) archive.addfile(info, io.BytesIO(data)) path.write_bytes(bz2.compress(buffer.getvalue())) diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index 7493e6bd7..bdb801e2b 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -236,6 +236,9 @@ def setups(spec: 'dict[str, Any]') -> 'list[dict[str, Any]]': {'python-version': '3.9', 'ktlo': True, 'os': 'ubuntu-24.04'}, {'python-version': '3.10', 'ktlo': True}] CONDA_AT_MAIN = {'os': ['macos-latest', 'ubuntu-latest'], 'python-version': SUPPORTED_AT_MAIN} +#: The conda jobs' ``os`` axis since #1822, which added the Intel macOS runner. +CONDA_JOBS = (('create-release.yml', 'conda'), ('cron-conda.yml', 'conda-dist')) +CONDA_OS = ['macos-latest', 'macos-26-intel', 'ubuntu-latest'] MAIN_MATRICES = { ('unit-tests.yml', 'test'): { 'python-version': SUPPORTED_AT_MAIN, @@ -569,6 +572,9 @@ def test_every_matrix_expands_to_mains_legs(self) -> None: leg.pop('coverage', None) if leg['python-version'] == TIERS_AT_MAIN['latest']: leg['coverage'] = True + if (name, job_id) in CONDA_JOBS: + # #1822: an Intel macOS leg per version, so osx-64 is built natively. + expected = legs(dict(spec, os=CONDA_OS)) if (name, job_id) in KTLO_TWINS: # #1738: the KTLO legs, in MAIN's order, are the twin job's. twin = KTLO_TWINS[(name, job_id)] diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py index 592691ab3..0af06a4e0 100644 --- a/tests/project/test_python_versions_update_1660.py +++ b/tests/project/test_python_versions_update_1660.py @@ -256,7 +256,8 @@ class TestTheTiers(unittest.TestCase): def test_the_labels_come_from_the_workflows(self) -> None: """``matrix.os || 'ubuntu-latest'``; the conda jobs' ``os`` axis; where ``latest`` is installed.""" found = context(snapshot()).labels - self.assertEqual(found, {'default': ['ubuntu-latest'], 'supported': ['macos-latest', 'ubuntu-latest'], + self.assertEqual(found, {'default': ['ubuntu-latest'], + 'supported': ['macos-26-intel', 'macos-latest', 'ubuntu-latest'], 'latest': ['macos-latest', 'ubuntu-latest']}) def test_ubuntu_latest_counts_as_its_image_and_every_newer_one(self) -> None: diff --git a/util/conda_vendor.py b/util/conda_vendor.py index f292b9f4e..5330b19f6 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -37,8 +37,12 @@ release and the weekly run refreshes them. With ``--smoke PACKAGE`` it unpacks a built ``.tar.bz2`` conda package and checks that every distribution vendored in it shipped every file its ``RECORD`` lists, and that each of its modules imports -from :file:`pcapkit/_extern` with nothing else on the path; that mode needs only -the standard library. +from :file:`pcapkit/_extern` with nothing else on the path. It also checks that +the package is for this runner's platform, and that every native binary in it +is for that platform too (#1822): ``conda convert`` relabels a package without +touching its binaries, so a relabelled one imports cleanly on the runner that +built it and fails everywhere it was relabelled for. That mode needs only the +standard library. """ @@ -47,7 +51,9 @@ import argparse import importlib.metadata import importlib.util +import json import pathlib +import platform import subprocess # nosec B404 import sys import tarfile @@ -57,7 +63,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Callable, Iterable, Optional + from typing import Callable, Iterable, Literal, Optional from packaging.markers import Marker from packaging.requirements import Requirement @@ -69,11 +75,27 @@ #: The extras the conda distribution carries, besides ``dependencies``. EXTRAS = ('all',) #: The platforms the conda workflows build on -- ``cron-conda.yml`` and -#: ``create-release.yml`` -- each as ``platform.system()`` and -#: ``platform.machine()`` read on its runner: ``ubuntu-latest`` and the arm64 -#: ``macos-latest``. Their Windows legs are commented out, and with them the -#: recipe's ``bld.bat``. -PLATFORMS = {'linux': ('Linux', 'x86_64'), 'darwin': ('Darwin', 'arm64')} +#: ``create-release.yml`` -- by the conda subdir each leg publishes, as +#: ``sys.platform``, ``platform.system()`` and ``platform.machine()`` read on its +#: runner: ``ubuntu-latest``, the Intel ``macos-26-intel`` and the arm64 +#: ``macos-latest``. Each leg publishes only its own runner's platform (#1822). +#: Their Windows legs are commented out, and with them the recipe's ``bld.bat``. +PLATFORMS = { + 'linux-64': ('linux', 'Linux', 'x86_64'), + 'osx-64': ('darwin', 'Darwin', 'x86_64'), + 'osx-arm64': ('darwin', 'Darwin', 'arm64'), +} +#: The object format and the machine of every native binary a package for each +#: of :data:`PLATFORMS` may carry (#1822). A universal Mach-O passes when one of +#: its slices is the machine. +BINARIES = {'linux-64': ('ELF', 'x86_64'), 'osx-64': ('Mach-O', 'x86_64'), 'osx-arm64': ('Mach-O', 'arm64')} +#: What ELF's ``e_machine`` and Mach-O's ``cputype`` say, as :data:`BINARIES` spells it. +ELF_MACHINES = {3: 'i386', 20: 'ppc', 21: 'ppc64', 40: 'arm', 62: 'x86_64', 183: 'arm64', 243: 'riscv'} +MACHO_CPUS = {7: 'i386', 0x01000007: 'x86_64', 12: 'arm', 0x0100000c: 'arm64', 0x0200000c: 'arm64_32', + 18: 'ppc', 0x01000012: 'ppc64'} +#: File names a native extension or library goes by, so one whose header this +#: cannot read is reported rather than passed over. +NATIVE_SUFFIXES = ('.so', '.dylib', '.pyd', '.dll') def _load_python_versions() -> 'object': @@ -96,11 +118,11 @@ def environments(pythons: 'Iterable[str]') -> 'list[dict[str, str]]': """A marker environment per conda build: each Python on each of :data:`PLATFORMS`.""" found = [] for python in pythons: - for platform, (system, machine) in PLATFORMS.items(): + for sys_platform, system, machine in PLATFORMS.values(): found.append({ 'python_version': python, 'python_full_version': f'{python}.0', 'implementation_name': 'cpython', 'platform_python_implementation': 'CPython', - 'implementation_version': f'{python}.0', 'sys_platform': platform, + 'implementation_version': f'{python}.0', 'sys_platform': sys_platform, 'platform_system': system, 'os_name': 'posix', 'platform_machine': machine, 'platform_release': '', 'platform_version': '', }) @@ -338,8 +360,69 @@ def _version(name: 'str') -> 'str': ''') +def native(head: 'bytes') -> 'Optional[tuple[str, frozenset[str]]]': + """The object format and machines of a binary starting with ``head``; :data:`None` for any other file.""" + magic = head[:4] + if magic == b'\x7fELF' and len(head) >= 20 and head[5] in (1, 2): + order = 'little' if head[5] == 1 else 'big' # type: Literal['little', 'big'] + machine = int.from_bytes(head[18:20], order) + return 'ELF', frozenset((ELF_MACHINES.get(machine, f'e_machine {machine}'),)) + thin = {b'\xcf\xfa\xed\xfe': 'little', b'\xce\xfa\xed\xfe': 'little', + b'\xfe\xed\xfa\xcf': 'big', b'\xfe\xed\xfa\xce': 'big'} # type: dict[bytes, Literal['little', 'big']] + if magic in thin and len(head) >= 8: + cpu = int.from_bytes(head[4:8], thin[magic]) + return 'Mach-O', frozenset((MACHO_CPUS.get(cpu, f'cputype {cpu:#x}'),)) + if magic in (b'\xca\xfe\xba\xbe', b'\xca\xfe\xba\xbf') and len(head) >= 8: + count = int.from_bytes(head[4:8], 'big') + # A Java class file shares the magic, and there the next word is its + # version, 45 and up, where a universal binary counts its slices. + if not 0 < count < 30: + return None + size = 20 if magic == b'\xca\xfe\xba\xbe' else 32 + cpus = [int.from_bytes(head[8 + size * index:12 + size * index], 'big') for index in range(count)] + return 'Mach-O', frozenset(MACHO_CPUS.get(cpu, f'cputype {cpu:#x}') for cpu in cpus) + return None + + +def foreign(root: 'pathlib.Path', subdir: 'str') -> 'tuple[int, list[str]]': + """How many native binaries ``root`` holds, and each one that does not run on ``subdir``. + + ``noarch`` may hold none; a platform outside :data:`BINARIES` fails every one. + A file named as a native library whose header is neither ELF nor Mach-O is + reported too, since this cannot vouch for it. + + """ + want = BINARIES.get(subdir) + count, problems = 0, [] + for path in sorted(root.rglob('*')): + if path.is_symlink() or not path.is_file(): + continue + with path.open('rb') as file: + head = file.read(4096) + found = native(head) + name = path.relative_to(root).as_posix() + if found is None: + if path.name.endswith(NATIVE_SUFFIXES) or '.so.' in path.name: + problems.append(f'{name}: neither ELF nor Mach-O, so its machine is unknown') + continue + count += 1 + kind, machines = found + if want is None or kind != want[0] or want[1] not in machines: + what = f'{kind} {", ".join(sorted(machines))}' + problems.append(f'{name}: {what}, but {subdir} has no native machine' if want is None else + f'{name}: {what}, not {want[0]} {want[1]} as {subdir} needs') + return count, problems + + +def runner_platform() -> 'Optional[str]': + """This machine's conda subdir among :data:`PLATFORMS`, or :data:`None` when it is none of them.""" + here = (platform.system(), platform.machine()) + return next((subdir for subdir, (_, system, machine) in PLATFORMS.items() if (system, machine) == here), + None) + + def smoke(package: 'pathlib.Path') -> 'int': - """Unpack a built ``.tar.bz2`` conda package and run :data:`PROBE` against it.""" + """Unpack a built ``.tar.bz2`` conda package, check its platform and binaries, and run :data:`PROBE`.""" with tempfile.TemporaryDirectory() as temp: with tarfile.open(package, 'r:bz2') as archive: archive.extractall(temp, filter='data') @@ -352,8 +435,26 @@ def smoke(package: 'pathlib.Path') -> 'int': if built != f'python{sys.version_info.major}.{sys.version_info.minor}': print(f'{package} is built for {built}, and this is {sys.version}', file=sys.stderr) return 1 - return subprocess.run([sys.executable, '-I', '-S', '-c', PROBE, str(site)], # nosec B603 - check=False).returncode + try: + index = json.loads((pathlib.Path(temp) / 'info' / 'index.json').read_text(encoding='utf-8')) + subdir = str(index['subdir']) + except (OSError, ValueError, TypeError, KeyError) as error: + print(f'{package}: info/index.json names no subdir ({error!r}), so its platform is unknown', + file=sys.stderr) + return 1 + runner = runner_platform() + if subdir not in ('noarch', runner): + print(f'{package} is filed under {subdir}, and this runner is ' + f'{runner or " ".join((platform.system(), platform.machine()))}: ' + 'a package is checked on the platform it is for', file=sys.stderr) + return 1 + count, problems = foreign(pathlib.Path(temp), subdir) + for problem in problems: + print(f'FOREIGN {problem}') + print(f'{subdir}: {count} native binaries read, {len(problems) or "none"} failing', flush=True) + status = subprocess.run([sys.executable, '-I', '-S', '-c', PROBE, str(site)], # nosec B603 + check=False).returncode + return 1 if problems else status def main(argv: 'Optional[list[str]]' = None) -> 'int':