Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/cron-conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/source/contributing/releasing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,12 +162,12 @@ the default branch, and ``tag`` pulled ``main`` before tagging.
naming another commit skips the gate and every publisher
(`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/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 <https://github.com/JarryShaw/PyPCAPKit/issues/1640>`__): the action
Expand Down Expand Up @@ -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.
Expand Down
102 changes: 102 additions & 0 deletions tests/project/test_conda_legs_install_no_pcapkit_1854.py
Original file line number Diff line number Diff line change
@@ -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()
121 changes: 121 additions & 0 deletions tests/project/test_release_conda_leg_smoke_1855.py
Original file line number Diff line number Diff line change
@@ -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<<EOF\n(.*?)^EOF$', written, re.MULTILINE | re.DOTALL)
self.assertIsNotNone(packages, log)
self.assertEqual(host, packages.group(1).splitlines()) # type: ignore[union-attr]
self.assertEqual(pathlib.PurePosixPath(host[0]).parent.name, 'linux-64')


if __name__ == '__main__':
unittest.main()
Loading
Loading