diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 79fa6a814..91a241f0c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -472,8 +472,8 @@ jobs: echo "http $status, found $count of $expected expected files for ${PCAPKIT_VERSION} on PyPI" echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT" - # Anaconda evidence, same shape as PyPI's above but for the ``conda`` - # matrix (linux-64 and osx-64, each Python read above). Unlike PyPI's, this + # Anaconda evidence, same shape as PyPI's above but for the ``conda`` matrix + # (each platform its legs publish, 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,11 +488,11 @@ jobs: run: | set -euo pipefail - # 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). + # Each platform the ``conda`` legs publish, for each supported version. + export PCAPKIT_CONDA_SUBDIRS="linux-64 osx-64 osx-arm64" + read -r -a subdirs <<< "$PCAPKIT_CONDA_SUBDIRS" read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" - expected=$(( 2 * ${#builds[@]} )) + expected=$(( ${#subdirs[@]} * ${#builds[@]} )) # Same coupling risk, and the same fix, as the PyPI check above. status="$(curl -s --retry 3 --retry-connrefused \ -o /tmp/anaconda_release.json -w '%{http_code}' \ @@ -507,7 +507,7 @@ jobs: "import json, os" \ "with open('/tmp/anaconda_release.json') as f:" \ " data = json.load(f)" \ - "platforms = {'linux-64', 'osx-64'}" \ + "platforms = set(os.environ['PCAPKIT_CONDA_SUBDIRS'].split())" \ "py_tags = set(os.environ['PCAPKIT_CONDA_BUILDS'].split())" \ "seen = {" \ " (d.get('attrs', {}).get('subdir'), tag)" \ diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index df6187e10..38c433dda 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -342,9 +342,9 @@ checks *is* the one it is about to create: already idempotent (``skip-existing: true`` on every upload step), 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 (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 + ``linux-64``, ``osx-64`` and ``osx-arm64`` files for each Python version of + its matrix, one platform per ``os`` leg, + `#1922 `__) 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 diff --git a/tests/project/test_conda_completeness_counts_every_leg_1922.py b/tests/project/test_conda_completeness_counts_every_leg_1922.py new file mode 100644 index 000000000..74ee83e16 --- /dev/null +++ b/tests/project/test_conda_completeness_counts_every_leg_1922.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""``version_check``'s Anaconda completeness check counts every platform the ``conda`` legs publish (#1922). + +* The platforms it counts are the ones the ``conda`` job's ``os`` legs map to, + read from that job's matrix and its ``case "${{ matrix.os }}"`` arms. +* Run as the workflow runs it, with ``curl`` stubbed, a release reads complete + only with each of those platforms for each build, so a missing ``osx-arm64`` + upload reads incomplete and the ``conda`` gate runs the leg that lacks it. + +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import shutil +import subprocess # nosec: B404 +import tempfile +import unittest +from typing import TYPE_CHECKING + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / '.github' / 'workflows' / 'create-release.yml' +STEP = 'Check Anaconda publish completeness' +BUILDS = ('py313', 'py314') + +#: A ``case "${{ matrix.os }}" in`` arm setting ``target_platform``. +ARM = re.compile(r'^\s*([\w.-]+)\)\s*\n\s*target_platform="([^"]*)"', re.MULTILINE) +#: The check's own list of platforms, which sets both its count and its set. +SUBDIRS = re.compile(r'^\s*export PCAPKIT_CONDA_SUBDIRS="([^"]*)"\s*$', re.MULTILINE) + + +def jobs() -> '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(WORKFLOW.read_text(encoding='utf-8'))['jobs'] + + +def check() -> 'dict[str, Any]': + return next(step for step in jobs()['version_check']['steps'] if step.get('name') == STEP) + + +def published() -> 'set[str]': + """The platform each ``conda`` leg publishes, from its matrix and each ``case`` of its steps.""" + spec = jobs()['conda'] + labels = spec['strategy']['matrix']['os'] + found = set() + for step in spec['steps']: + for block in re.findall(r'case "\$\{\{ matrix\.os \}\}" in\n(.*?)\n\s*esac', str(step.get('run', '')), + re.DOTALL): + arms = dict(ARM.findall(block)) + found.add(frozenset(arms[label] for label in labels)) + assert len(found) == 1, found + return set(found.pop()) + + +class TheCheckCountsTheLegs(unittest.TestCase): + """What the check counts is what the legs publish.""" + + def test_the_check_lists_each_published_platform(self) -> None: + listed = SUBDIRS.findall(check()['run']) + self.assertEqual(len(listed), 1, 'the check names its platforms in one PCAPKIT_CONDA_SUBDIRS list') + self.assertEqual(sorted(listed[0].split()), sorted(published())) + self.assertIn('osx-arm64', published()) + + +@unittest.skipIf(shutil.which('bash') is None or shutil.which('python3') is None, + 'bash and python3 are needed to run the workflow shell') +class TheCheckRunsAsTheWorkflowRunsIt(unittest.TestCase): + """The step's script, with ``curl`` answering from a fake Anaconda release.""" + + def complete(self, distributions: 'list[tuple[str, str]]') -> 'tuple[str, str]': + """``PCAPKIT_CONDA_COMPLETE`` and the log for a release holding these (platform, build) pairs.""" + step = check() + self.assertEqual(sorted(step['env']), ['PCAPKIT_CONDA_BUILDS', 'PCAPKIT_VERSION']) + with tempfile.TemporaryDirectory(prefix='pcapkit-1922-') as tmp: + scratch = pathlib.Path(tmp) + reply = scratch / 'reply.json' + reply.write_text(json.dumps({'distributions': [{'attrs': {'subdir': platform, 'build': f'{build}_0'}} + for platform, build in distributions]}), encoding='utf-8') + curl = scratch / 'curl' + curl.write_text('#!/bin/bash\nwhile [ $# -gt 0 ]; do [ "$1" = -o ] && cp "$REPLY" "$2"; shift; done\n' + 'printf 200\n', encoding='utf-8') + curl.chmod(0o755) + output = scratch / 'output' + output.write_text('', encoding='utf-8') + result = subprocess.run( # nosec: B603 B607 + ['bash', '-e', '-c', step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json'))], + env=dict(os.environ, PATH=f'{scratch}{os.pathsep}{os.environ["PATH"]}', REPLY=str(reply), + GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=' '.join(BUILDS)), + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + written = dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').split()) + return written['PCAPKIT_CONDA_COMPLETE'], result.stdout + + def test_every_platform_for_every_build_reads_complete(self) -> None: + every = [(platform, build) for platform in sorted(published()) for build in BUILDS] + state, log = self.complete(every) + self.assertEqual(state, 'true', log) + self.assertIn(f'found {len(every)} of {len(every)} expected', log) + + def test_a_missing_platform_reads_incomplete(self) -> None: + for missing in sorted(published()): + rest = [(platform, build) for platform in sorted(published() - {missing}) for build in BUILDS] + with self.subTest(missing=missing): + self.assertEqual(self.complete(rest)[0], 'false') + + def test_a_missing_osx_arm64_build_reads_incomplete(self) -> None: + every = [(platform, build) for platform in sorted(published()) for build in BUILDS] + state, log = self.complete([pair for pair in every if pair != ('osx-arm64', BUILDS[-1])]) + self.assertEqual(state, 'false', log) + self.assertIn(f'found {len(every) - 1} of {len(every)} expected', log) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index 8090782f7..0538530db 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -855,7 +855,7 @@ def complete(self, distributions: 'list[tuple[str, str]]') -> 'str': def test_each_platform_needs_each_supported_build(self) -> None: builds = live_outputs()['conda-builds'].split() - every = [(platform, build) for platform in ('linux-64', 'osx-64') for build in builds] + every = [(platform, build) for platform in ('linux-64', 'osx-64', 'osx-arm64') for build in builds] ktlo = [('linux-64', 'py' + version.replace('.', '')) for version in script().tier(script().load(), 'cpython', 'ktlo')] self.assertEqual(self.complete(every), 'true') diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 77f66a414..287da7edd 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -2378,10 +2378,11 @@ def test_pypi_evidence_reads_the_per_version_endpoint(self) -> None: self.assertIn('pypi.org/pypi/pypcapkit/', block) self.assertIn("data.get('urls'", block) - def test_conda_evidence_checks_both_platforms(self) -> None: + def test_conda_evidence_checks_every_platform(self) -> None: block = self.jobs['version_check'] self.assertIn('linux-64', block) self.assertIn('osx-64', block) + self.assertIn('osx-arm64', block) class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index f0e479f8a..cc9ceeb6f 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -1245,7 +1245,7 @@ def test_a_finished_release_reads_complete_after_the_tiers_grow(self) -> None: files = [f'pypcapkit-{self.VERSION}-{tag}-none-any.whl' for tag in released['pypi-wheels'].split()] pypi = {'urls': [{'filename': name} for name in files + [f'pypcapkit-{self.VERSION}.tar.gz']]} conda = {'distributions': [{'attrs': {'subdir': platform, 'build': f'{build}_0'}} - for platform in ('linux-64', 'osx-64') + for platform in ('linux-64', 'osx-64', 'osx-arm64') for build in released['conda-builds'].split()]} step = 'Check PyPI publish completeness' self.assertEqual(self.complete(step, written['pypi-wheels'], '', pypi), 'true')