From 5d22e159d40c4254bedaa0b60fab8239d047d47e Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 16 Oct 2026 19:40:38 -0400 Subject: [PATCH] ci(release): drop fixed totals and conda-leg installs, smoke-test releases (#1849, #1854, #1855) - release_status: the steady-state "Nothing to release" notice no longer states PyPI and Anaconda totals, which version_check derives from the tag's own tier file. - conda-dist and the release conda leg: drop `pip install -e .[all]`, and the release leg's `pathlib2 typing_extensions`. No later step imports pcapkit from setup-python's interpreter, and on macos-26-intel the extras pull a cryptography with no Intel wheel. - Release conda leg: the build step exports `host_package`, and cron-conda's `--smoke` step runs before anything is uploaded. - releasing.rst: re-point the create-release.yml citations the new steps shifted. - tests/project: a regression test per issue, each failing without its fix. Closes #1849 Closes #1854 Closes #1855 --- .github/workflows/create-release.yml | 24 +++- .github/workflows/cron-conda.yml | 4 +- docs/source/contributing/releasing.rst | 8 +- ...test_conda_legs_install_no_pcapkit_1854.py | 102 +++++++++++++++ .../test_release_conda_leg_smoke_1855.py | 121 ++++++++++++++++++ ...st_release_notice_states_no_totals_1849.py | 82 ++++++++++++ 6 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 tests/project/test_conda_legs_install_no_pcapkit_1854.py create mode 100644 tests/project/test_release_conda_leg_smoke_1855.py create mode 100644 tests/project/test_release_notice_states_no_totals_1849.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 091c3f73b..79fa6a814 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1338,21 +1338,23 @@ jobs: with: python-version: ${{ matrix.python-version }} + # #1854: nothing below imports pcapkit from this interpreter. ``pip + # download`` needs pip alone, ``conda build`` installs into its own host + # environment, the version comes from job outputs, and the smoke test + # runs its probe under ``-I -S``. So pcapkit and its extras are not + # installed here: on ``macos-26-intel`` they resolve a cryptography with + # no Intel wheel, which pip has to compile or fail on. - name: Install and Setup if: ${{ matrix.os != 'windows-latest' }} run: | set -x python -m pip install -U pip setuptools wheel - python -m pip install -e .[all] - python -m pip install pathlib2 typing_extensions - name: Install and Setup (Windows) if: ${{ matrix.os == 'windows-latest' }} run: | python -m pip install -U pip setuptools wheel - python -m pip install -e .[all] - python -m pip install pathlib2 typing_extensions - name: Setup Conda Distribution if: ${{ matrix.os != 'windows-latest' }} @@ -1435,6 +1437,8 @@ jobs: ;; esac + echo "host_package=$host_package" >> "$GITHUB_OUTPUT" + # #1822: never ``conda convert`` across architectures; see the matrix. host_platform="$(basename "$(dirname "$host_package")")" if [ "$host_platform" != "$target_platform" ]; then @@ -1457,6 +1461,16 @@ jobs: echo EOF } >> "$GITHUB_OUTPUT" + # #1855: cron-conda.yml's ``conda-dist`` step of the same name, which says + # what it checks and why it runs setup-python's interpreter, so a release + # package is checked as a Conda Update bump's is, before anything below + # uploads it. Not gated on ``check_leg``, since the GitHub upload is not. + - name: Smoke-test the vendored packages + env: + PCAPKIT_CONDA_PACKAGE: ${{ steps.conda_build.outputs.host_package }} + run: | + "${pythonLocation}/bin/python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE" + - name: Upload conda packages to Anaconda if: ${{ steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }} uses: anaconda/actions/upload-package@v0.3.1 @@ -1690,7 +1704,7 @@ jobs: # Case 2: every target reconciled by skipping, none of them ran. # Quiet -- this is the steady state most runs are in. if [ "$all_reconciled" = "true" ] && [ "$all_skipped" = "true" ]; then - echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag, all 7 supported PyPI files and all 10 Anaconda files are present. This run correctly released nothing." + echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag and every PyPI and Anaconda file version_check expects are present. This run correctly released nothing." exit 0 fi diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 10bd7eccf..b4cce5d47 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -445,19 +445,19 @@ jobs: with: python-version: ${{ matrix.python-version }} + # #1854: pcapkit is not installed here; see create-release.yml's step of + # the same name. - name: Install and Setup if: ${{ matrix.os != 'windows-latest' }} run: | set -x python -m pip install -U pip setuptools wheel - python -m pip install -e .[all] - name: Install and Setup (Windows) if: ${{ matrix.os == 'windows-latest' }} run: | python -m pip install -U pip setuptools wheel - python -m pip install -e .[all] # #1827: pip evaluates conda/requirements.txt's platform markers here, # on this leg's own runner, so Intel macOS fetches its own cryptography diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 03030bcb5..df6187e10 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -84,7 +84,7 @@ a workaround for another: 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 ``:719`` and the release ``pypi`` and - ``conda`` upload to at ``:1117`` and ``:1479``, always from + ``conda`` upload to at ``:1117`` and ``:1493``, 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 @@ -162,12 +162,12 @@ the default branch, and ``tag`` pulled ``main`` before tagging. naming another commit skips the gate and every publisher (`#1748 `__): ``Release status`` posts a ``::notice`` if that release is complete, or a - ``::warning`` naming both commits and what is missing (``:1624-1634``), and + ``::warning`` naming both commits and what is missing (``:1638-1648``), 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`` - (``:1110-1147``, ``:1474-1509``), not ``action-gh-release``, which calls Update + (``:1110-1147``, ``:1488-1523``), 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 @@ -296,7 +296,7 @@ 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`` -(``:1586-1588``). A tag pushed by a person runs as before. Whether a tag made +(``:1600-1602``). 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. diff --git a/tests/project/test_conda_legs_install_no_pcapkit_1854.py b/tests/project/test_conda_legs_install_no_pcapkit_1854.py new file mode 100644 index 000000000..0a9200eeb --- /dev/null +++ b/tests/project/test_conda_legs_install_no_pcapkit_1854.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""The conda legs do not install pcapkit into setup-python's interpreter (#1854). + +``conda-dist`` in :file:`.github/workflows/cron-conda.yml` and ``conda`` in +:file:`.github/workflows/create-release.yml` ran ``pip install -e .[all]``, and +the release leg ``pip install pathlib2 typing_extensions`` too, for no later +step: ``pip download`` needs pip alone, ``conda build`` installs into its own +host environment, the version comes from job outputs, and ``--smoke`` runs its +probe under ``-I -S``. On ``macos-26-intel`` the extras resolve a cryptography +with no Intel wheel, which pip has to compile or fail on. The jobs are read +with :func:`yaml.safe_load`. + +""" + +from __future__ import annotations + +import pathlib +import re +import shlex +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 + +WORKFLOWS = pathlib.Path(__file__).resolve().parents[2] / '.github' / 'workflows' +#: The conda jobs, as (workflow, job). +JOBS = (('cron-conda.yml', 'conda-dist'), ('create-release.yml', 'conda')) +#: What each install step still upgrades: ``pip download`` runs on this pip. +BUILD_TOOLS = ['python', '-m', 'pip', 'install', '-U', 'pip', 'setuptools', 'wheel'] +#: The backports #1821 removed from ``cron-conda.yml``. +BACKPORTS = {'pathlib2', 'typing_extensions', 'typing-extensions'} +#: A step running pcapkit from setup-python's interpreter, which would need it installed. +USES_PCAPKIT = re.compile(r'\bimport\s+pcapkit\b|-m\s+pcapkit\b|\bpcapkit-(?:cli|vendor)\b') + + +def commands(step: 'dict[str, Any]') -> 'list[str]': + """The step's ``run`` lines that are not blank or comments.""" + return [line.strip() for line in str(step.get('run', '')).splitlines() + if line.strip() and not line.lstrip().startswith('#')] + + +def installs(step: 'dict[str, Any]') -> 'list[list[str]]': + """Each ``python -m pip install`` command of the step, split as the shell splits it.""" + found = [] + for line in commands(step): + if re.search(r'\bpip\s+install\b', line): + words = shlex.split(line) + if words[1:4] != ['-m', 'pip', 'install']: + raise AssertionError(f'not run as `python -m pip install`, so not read here: {line}') + found.append(words) + return found + + +@unittest.skipIf(yaml is None, 'PyYAML, from the test extra, is not installed') +class TestTheCondaLegsInstallOnlyTheBuildTools(unittest.TestCase): + """setup-python's interpreter gets pip, setuptools and wheel, and nothing to import.""" + + def steps(self) -> 'list[tuple[str, str, dict[str, Any]]]': + found = [] + for name, job_id in JOBS: + spec = yaml.safe_load((WORKFLOWS / name).read_text(encoding='utf-8'))['jobs'][job_id] + found.extend((name, job_id, step) for step in spec['steps']) + return found + + def test_no_leg_installs_the_project(self) -> None: + for name, job_id, step in self.steps(): + for words in installs(step): + targets = [word for word in words[4:] if not word.startswith('-')] + with self.subTest(workflow=name, job=job_id, step=step.get('name'), command=words): + self.assertFalse([word for word in targets if word == '.' or word.startswith('.[')]) + + def test_no_leg_installs_the_backports(self) -> None: + for name, job_id, step in self.steps(): + for words in installs(step): + with self.subTest(workflow=name, job=job_id, step=step.get('name'), command=words): + self.assertFalse(BACKPORTS.intersection(words)) + + def test_each_install_step_upgrades_the_build_tools_alone(self) -> None: + for name, job_id in JOBS: + steps = [step for found, job, step in self.steps() if (found, job) == (name, job_id) + and str(step.get('name', '')).startswith('Install and Setup')] + with self.subTest(workflow=name, job=job_id): + self.assertEqual(len(steps), 2, 'the Linux/macOS step and the Windows one') + for step in steps: + with self.subTest(workflow=name, job=job_id, step=step['name']): + self.assertEqual(installs(step), [BUILD_TOOLS]) + + def test_no_step_runs_pcapkit_from_that_interpreter(self) -> None: + """What would need the install back: an import, ``-m pcapkit`` or an entry point.""" + for name, job_id, step in self.steps(): + with self.subTest(workflow=name, job=job_id, step=step.get('name')): + self.assertFalse([line for line in commands(step) if USES_PCAPKIT.search(line)]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_release_conda_leg_smoke_1855.py b/tests/project/test_release_conda_leg_smoke_1855.py new file mode 100644 index 000000000..ac077d547 --- /dev/null +++ b/tests/project/test_release_conda_leg_smoke_1855.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""The release conda leg smoke-tests its package as Conda Update does (#1855). + +#1821 added ``util/conda_vendor.py --smoke`` to ``conda-dist`` in +:file:`.github/workflows/cron-conda.yml`, after ``conda build`` and before any +upload. ``conda`` in :file:`.github/workflows/create-release.yml`, which +publishes the release packages, had no such step, and its build step did not +write the ``host_package`` output the step reads. The jobs are read with +:func:`yaml.safe_load`, and each build step is run with a stand-in ``conda``, +as ``test_conda_native_platforms_1822.py`` runs it. + +""" + +from __future__ import annotations + +import os +import pathlib +import re +import stat +import subprocess # nosec: B404 +import tempfile +import unittest +from typing import TYPE_CHECKING + +from tests._support import scale_timeout +from tests.project.test_conda_native_platforms_1822 import BASH, CONDA + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any + +WORKFLOWS = pathlib.Path(__file__).resolve().parents[2] / '.github' / 'workflows' +#: The conda jobs, as (workflow, job); the first is the one the release mirrors. +CRON, RELEASE = ('cron-conda.yml', 'conda-dist'), ('create-release.yml', 'conda') +#: The steps that publish what the leg built. +UPLOADS = ('Upload conda packages to Anaconda', 'Upload package to GitHub Release') + + +def steps(workflow: 'str', job_id: 'str') -> 'list[dict[str, Any]]': + return yaml.safe_load((WORKFLOWS / workflow).read_text(encoding='utf-8'))['jobs'][job_id]['steps'] + + +def smoke_steps(found: 'list[dict[str, Any]]') -> 'list[int]': + """The index of each step that runs ``util/conda_vendor.py --smoke``.""" + return [index for index, step in enumerate(found) + if re.search(r'\butil/conda_vendor\.py\s+--smoke\b', str(step.get('run', '')))] + + +@unittest.skipIf(yaml is None, 'PyYAML, from the test extra, is not installed') +class TestTheReleaseLegSmokeTests(unittest.TestCase): + """The release leg checks its package before publishing it.""" + + def test_the_smoke_test_runs_after_the_build_and_before_every_upload(self) -> None: + for workflow, job_id in (CRON, RELEASE): + found = steps(workflow, job_id) + smoke = smoke_steps(found) + with self.subTest(workflow=workflow, job=job_id): + self.assertEqual(len(smoke), 1, f'{workflow} {job_id} runs no smoke test') + build = next(index for index, step in enumerate(found) if step.get('id') == 'conda_build') + self.assertGreater(smoke[0], build) + uploads = [index for index, step in enumerate(found) if step.get('name') in UPLOADS] + self.assertEqual(len(uploads), len(UPLOADS)) + self.assertLess(smoke[0], min(uploads)) + + def test_it_is_conda_updates_step(self) -> None: + """The same interpreter, package and gate: setup-python's, ``host_package``, none.""" + cron = steps(*CRON) + release = steps(*RELEASE) + expected = cron[smoke_steps(cron)[0]] + self.assertEqual(expected['env'], {'PCAPKIT_CONDA_PACKAGE': '${{ steps.conda_build.outputs.host_package }}'}) + self.assertIn('"${pythonLocation}/bin/python" util/conda_vendor.py --smoke', expected['run']) + found = smoke_steps(release) + self.assertEqual(len(found), 1, 'the release leg runs no smoke test') + self.assertEqual(release[found[0]], expected) + + +@unittest.skipIf(yaml is None, 'PyYAML, from the test extra, is not installed') +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestEachBuildStepNamesItsPackage(unittest.TestCase): + """``host_package``, which the smoke step reads, is the package the step built.""" + + def outputs(self, workflow: 'str', job_id: 'str') -> 'tuple[str, str]': + """The build step's ``$GITHUB_OUTPUT`` on ``ubuntu-latest``, and its log.""" + step = next(step for step in steps(workflow, job_id) if step.get('id') == 'conda_build') + script = step['run'].replace('${{ matrix.os }}', 'ubuntu-latest').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='linux-64', + PCAPKIT_VERSION='1.0', PCAPKIT_BUILD='0'), + capture_output=True, text=True, check=False, timeout=scale_timeout(60)) + return output.read_text(encoding='utf-8'), result.stdout + result.stderr + + def test_host_package_is_the_package_built(self) -> None: + for workflow, job_id in (CRON, RELEASE): + written, log = self.outputs(workflow, job_id) + with self.subTest(workflow=workflow, job=job_id): + host = re.findall(r'^host_package=(.*)$', written, re.MULTILINE) + self.assertEqual(len(host), 1, f'no host_package output\n{written}{log}') + packages = re.search(r'^packages< 'str': + """The ``Nothing to release`` notice the step prints in :data:`STEADY`.""" + steps = yaml.safe_load(WORKFLOW.read_text(encoding='utf-8'))['jobs']['release_status']['steps'] + step = next(step for step in steps if 'run' in step) + env = dict.fromkeys(step['env'], '') + env.update(STEADY, PATH=os.environ.get('PATH', os.defpath)) + result = subprocess.run([BASH, '-c', step['run']], env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=scale_timeout(10)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + found = [line for line in result.stdout.splitlines() + if line.startswith('::notice title=Nothing to release::')] + self.assertEqual(len(found), 1, result.stdout) + return found[0] + + def test_the_steady_state_notice_counts_nothing(self) -> None: + notice = self.notice() + self.assertIn(CONDA_TAG, notice) + self.assertIsNone(re.search(r'\d', notice.replace(CONDA_TAG, '')), + f'a total version_check does not compute here: {notice}') + + +if __name__ == '__main__': + unittest.main()