diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 25bbce6962..7716c501b1 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -75,8 +75,9 @@ concurrency: # exists, ``pypi`` PyPI's file count, and ``conda`` Anaconda's aggregate count # (a cost saver) plus, per leg, its own distribution -- the correctness fix, # since ``anaconda/actions/upload-package`` has no ``skip-existing``, only -# ``force:``, never set here. The ``macos-26-intel`` leg uploads with the -# anaconda-client CLI and ``--skip-existing`` instead (#2044). +# ``force:``, never set here. The ``macos-26-intel`` and ``windows-latest`` +# legs upload with the anaconda-client CLI and ``--skip-existing`` instead +# (#2044, #2060). # # GitHub skips a job whose ``needs:`` skipped before evaluating its ``if:``, # unless the ``if:`` calls a status-check function, and ``github`` skips on @@ -292,6 +293,8 @@ jobs: env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_CONDA_BUILDS: ${{ steps.released.outputs.conda-builds }} + # #2060: once tagged, the platforms its tag's commit published. + PCAPKIT_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} run: python3 util/release_published.py anaconda /tmp/anaconda_release.json # #1748: resolved as #1634's check in ``github``, ``pypi`` and ``conda`` @@ -749,8 +752,9 @@ jobs: - macos-latest - macos-26-intel - ubuntu-latest - # No Windows leg since 2023, and no Windows steps (#1967): adding the leg - # back means writing its steps, as test_conda_no_windows_steps_1967 pins. + # #2060: win-64, once Conda Update's leg had built and smoke-tested it + # green. Its `run:` steps name `bash`, as Windows runs `pwsh`. + - windows-latest # #1633, #1660: the supported tier only. No KTLO 3.10 leg: the list # util/conda_vendor.py derives omits typing-extensions, which # pcapkit/utilities/compat.py imports below 3.11, and pins each @@ -775,9 +779,11 @@ jobs: # Anaconda check, so this reads the live state. #1967: util/release_conda.py # holds this step's shell, and the build's and the Intel upload's, which # cron-conda.yml's ``conda-dist`` runs too; before setup-python, so here - # on the image's ``python3``. + # on the image's ``python3``. On Windows, Git Bash hands it ``/tmp`` as + # the user's temporary directory. - name: Check whether this leg is already on Anaconda id: check_leg + shell: bash env: PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} PCAPKIT_CONDA_OS: ${{ matrix.os }} @@ -795,12 +801,14 @@ jobs: # 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 + shell: bash run: | set -x python -m pip install -U pip setuptools wheel - name: Setup Conda Distribution + shell: bash run: | set -x @@ -836,7 +844,10 @@ jobs: conda-remove-defaults: false show-channel-urls: true + # A login shell, as for the build below: on Windows only that one has the + # `conda` setup-miniconda's `conda init` defines. - name: Install conda build tools + shell: bash -el {0} run: | conda install -n base --override-channels -c conda-forge -y conda-build conda build --version @@ -862,15 +873,21 @@ jobs: # 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 + shell: bash env: PCAPKIT_CONDA_PACKAGE: ${{ steps.conda_build.outputs.host_package }} run: | - "${pythonLocation}/bin/python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE" + python="${pythonLocation}/bin/python" + if [ "$RUNNER_OS" = Windows ]; then + python="$(cygpath -u "$pythonLocation")/python.exe" + fi + "$python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE" # #2044: not on macos-26-intel, whose leg "Upload conda packages to - # Anaconda (Intel)" uploads instead. + # Anaconda (Intel)" uploads instead, nor, #2060, on windows-latest, whose + # "Upload conda packages to Anaconda (Windows)" does. - name: Upload conda packages to Anaconda - if: ${{ matrix.os != 'macos-26-intel' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }} + if: ${{ matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }} uses: anaconda/actions/upload-package@a6f488910b4a1e4f3fe01736241ca1b92b4b355a # v0.3.1 with: token: ${{ secrets.ANACONDA_TOKEN }} @@ -898,6 +915,21 @@ jobs: PCAPKIT_CONDA_LABELS: ${{ needs.version_check.outputs.PCAPKIT_CONDA_LABEL }} run: python3 util/release_conda.py upload + # #2060: the Intel leg's call, on win-64, under a login shell as "Build + # conda packages" is: only there is base active, so the script can start + # `conda` as base's `Scripts\conda.exe`, and `python` is base's. The + # script runs base's `Scripts\anaconda.exe`. + - name: Upload conda packages to Anaconda (Windows) + if: ${{ matrix.os == 'windows-latest' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }} + shell: bash -el {0} + env: + ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_TOKEN }} + ANACONDA_CLI_FORCE_NEW: "1" + PCAPKIT_CONDA_CHANNEL: jarryshaw + PCAPKIT_CONDA_PACKAGES: ${{ steps.conda_build.outputs.packages }} + PCAPKIT_CONDA_LABELS: ${{ needs.version_check.outputs.PCAPKIT_CONDA_LABEL }} + run: python util/release_conda.py upload + # ``pypi``'s upload script, for the same reasons (#1634, #1640). Not # gated on ``check_leg`` as the Anaconda upload is: a leg whose Anaconda # upload landed and whose GitHub upload then failed would get no GitHub diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index b872d476bf..0241644f1a 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -445,12 +445,6 @@ jobs: )) permissions: contents: write - # #2060: the Windows leg builds and smoke-tests only, and never blocks. Each - # step that can fail on it has an `id`, and a `continue-on-error` true on - # `windows-latest` alone, so a failure leaves the leg at success, and - # `Windows summary` reports it as a warning, as the KTLO legs do (#1672). - # No job-level `continue-on-error`, which would still turn the leg's check - # red (#1672), so a lost runner fails the job. strategy: fail-fast: false matrix: @@ -460,20 +454,17 @@ jobs: - macos-latest - macos-26-intel - ubuntu-latest - # #2060: builds and smoke-tests win-64, and publishes nothing until - # its runs pass. Every upload step below is gated off it, so the - # `ANACONDA_TOKEN` this job's `environment:` holds reaches no step on it. + # #2060: win-64, uploaded with the anaconda-client CLI as the Intel + # leg is, now that its runs have built and smoke-tested it green. - windows-latest # #1633: the supported tier, from .github/python-versions.toml through # `conda-update` (#1660); create-release.yml's ``conda`` job says why # there is no KTLO 3.10 leg. python-version: ${{ fromJSON(needs.conda-update.outputs.PYTHON_CONDA) }} steps: - # No persisted token: nothing here runs `git` with credentials, and the - # Windows leg builds a package nobody has published yet (#2060). + # No persisted token: nothing here runs `git` with credentials (#2060). - id: checkout if: github.event_name != 'workflow_dispatch' - continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: conda-${{ needs.conda-update.outputs.PCAPKIT_VERSION }}+${{ needs.conda-update.outputs.PCAPKIT_BUILD }} @@ -484,7 +475,6 @@ jobs: # changes `conda-update` made to it and did not push. - id: checkout-dispatched if: github.event_name == 'workflow_dispatch' - continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -493,7 +483,6 @@ jobs: - name: Download conda-update's changes id: download-changes if: github.event_name == 'workflow_dispatch' - continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: conda-changes @@ -502,7 +491,6 @@ jobs: - name: Apply conda-update's changes id: apply-changes if: github.event_name == 'workflow_dispatch' - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash env: PATCH_FILE: ${{ runner.temp }}/conda-changes/conda-changes.patch @@ -512,7 +500,6 @@ jobs: fi - id: setup-python - continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -521,7 +508,6 @@ jobs: # the same name. `shell: bash` here and below, as Windows runs `pwsh`. - name: Install and Setup id: install - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash run: | set -x @@ -534,7 +520,6 @@ jobs: # this downloaded. - name: Setup Conda Distribution id: download - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash run: | set -x @@ -546,7 +531,6 @@ jobs: # exceptions (#2123). - name: Conda environment creation and activation id: miniconda - continue-on-error: ${{ matrix.os == 'windows-latest' }} uses: conda-incubator/setup-miniconda@be893c923ea9cf1cf7cd510fbdde27c7e18cbdcb # v4.1.0 env: PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} @@ -567,7 +551,6 @@ jobs: # `conda` setup-miniconda's `conda init` defines. - name: Install conda build tools id: conda-build-tools - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash -el {0} run: | conda install -n base --override-channels -c conda-forge -y conda-build @@ -578,7 +561,6 @@ jobs: # it fails a package built for another platform than the leg's. - name: Build conda packages id: conda_build - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash -el {0} env: PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} @@ -599,8 +581,6 @@ jobs: # is `python.exe` at the top of `pythonLocation`, not under `bin/` (#2060). - name: Smoke-test the vendored packages id: smoke - if: steps.conda_build.outcome == 'success' - continue-on-error: ${{ matrix.os == 'windows-latest' }} shell: bash env: PCAPKIT_CONDA_PACKAGE: ${{ steps.conda_build.outputs.host_package }} @@ -612,10 +592,9 @@ jobs: "$python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE" # #2044: not on macos-26-intel, whose leg "Upload conda packages to - # Anaconda (Intel)" uploads instead. #2060: nor on windows-latest, which - # publishes nothing until a run has shown its package works. - # Neither upload, nor the GitHub Release steps after them, runs on a - # manual run. + # Anaconda (Intel)" uploads instead, nor, #2060, on windows-latest, whose + # "Upload conda packages to Anaconda (Windows)" does. No upload, nor the + # GitHub Release steps after them, runs on a manual run. - name: Upload conda packages to Anaconda if: ${{ github.event_name != 'workflow_dispatch' && matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' }} uses: anaconda/actions/upload-package@a6f488910b4a1e4f3fe01736241ca1b92b4b355a # v0.3.1 @@ -640,6 +619,19 @@ jobs: PCAPKIT_CONDA_LABELS: ${{ needs.conda-update.outputs.PCAPKIT_CONDA_LABEL }} run: python3 util/release_conda.py upload + # #2060: see create-release.yml's step of the same name, which says why + # it runs under a login shell on conda's base `python`. + - name: Upload conda packages to Anaconda (Windows) + if: ${{ github.event_name != 'workflow_dispatch' && matrix.os == 'windows-latest' }} + shell: bash -el {0} + env: + ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_TOKEN }} + ANACONDA_CLI_FORCE_NEW: "1" + PCAPKIT_CONDA_CHANNEL: jarryshaw + PCAPKIT_CONDA_PACKAGES: ${{ steps.conda_build.outputs.packages }} + PCAPKIT_CONDA_LABELS: ${{ needs.conda-update.outputs.PCAPKIT_CONDA_LABEL }} + run: python util/release_conda.py upload + # #1634: the upload below never makes the release. ncipollo/release-action # v1.21.0 creates a missing one, and its tag, at ``commit`` # (src/Action.ts:72-92), so a bump made while Create Release waits on @@ -702,18 +694,6 @@ jobs: tag: "v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}" token: "${{ secrets.GITHUB_TOKEN }}" - # #2060: the Windows leg's last step, as `KTLO summary` is a KTLO leg's - # (#1672): one warning per failed step, from each `outcome`. setup-python's - # `python`, or the image's where that failed. - - name: Windows summary - if: ${{ !cancelled() && matrix.os == 'windows-latest' }} - continue-on-error: true - shell: bash - env: - LEG: conda-dist on ${{ matrix.os }} with Python ${{ matrix.python-version }} (unpublished, non-blocking) - STEPS: ${{ toJSON(steps) }} - run: python util/leg_summary.py conda-windows - # #2030: posts `conda-update`'s report with the tag and upload results, and a # short one of its own where `head` failed or was cancelled, as cron-vendor.yml's # `discussion` job does, whose comment gives the rules. It waits for diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index ec99eda154..6fcf0d4759 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -705,8 +705,7 @@ jobs: retention-days: 14 # `experimental`'s, on setup-python's `python`, or the image's where that - # failed, since Windows has no /usr/bin/python3, as cron-conda.yml's - # `Windows summary` runs it (#2060). + # failed, since Windows has no /usr/bin/python3 (#2060). - name: Experimental summary if: ${{ !cancelled() }} continue-on-error: true diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index c45e3b312e..745af88fa0 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -343,9 +343,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``, ``osx-64`` and ``osx-arm64`` files for each Python version of - its matrix, one platform per ``os`` leg, - `#1922 `__) as the same + ``linux-64``, ``osx-64``, ``osx-arm64`` and ``win-64`` files for each Python + version of its matrix, one platform per ``os`` leg, + `#1922 `__, :issue:`2060`) 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 @@ -355,9 +355,10 @@ checks *is* the one it is about to create: action's CLI download returns 404 there, so its "Upload conda packages to Anaconda (Intel)" step runs ``anaconda upload --skip-existing`` instead (`#2044 `__), which skips - such a file. Conda Update's Intel leg runs the same step, so it keeps a file - anaconda.org already has, where the action's ``force: true`` on its other legs - replaces it. + such a file. The ``windows-latest`` leg's "Upload conda packages to Anaconda + (Windows)" makes the same call (:issue:`2060`). Conda Update's Intel and + Windows legs run the same steps, so they keep a file anaconda.org already has, + where the action's ``force: true`` on its other legs replaces it. These evidence checks all run inside ``version_check``, curling PyPI's and Anaconda's public, unauthenticated JSON APIs; the per-leg check runs inside @@ -374,6 +375,10 @@ gate and ``pypi``, and fail `One Commit per Release`_'s tag check on every run until the next release. A tag older than the file, as ``v1.5.0b12`` is, counts the tiers the file was added with, which the change for `#1660 `__ copied unchanged from the workflows of the time. +The Anaconda count's platforms follow the same rule, from the ``SUBDIRS`` line +of :file:`util/release_published.py` at the tag's commit or, for a tag older +than that file, the line it was added with, so a version released before +``win-64`` was published is not read as lacking it (:issue:`2060`). On the tag-push path ``startsWith(github.ref_name, 'v')`` bypasses the evidence check unconditionally; on the ``workflow_run`` path it falls through diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 15066257ac..6bca159858 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1376,7 +1376,8 @@ to a script. Each script's docstring says what it does; each has tests under conda packages to Anaconda (Intel)`` in that job and in ``cron-conda.yml``'s ``conda-dist``, as ``release_conda.py build`` and ``upload`` (`#1822 `__, `#2044 - `__). + `__), and ``Upload conda + packages to Anaconda (Windows)`` in both as ``upload`` (:issue:`2060`). - No. * - :file:`util/release_reset_build.py` - ``Reset Build Number`` in ``create-release.yml``'s ``tag``, on the image's @@ -1789,14 +1790,13 @@ it, so the scheduled jobs reading them name it too * Conda Update's ``conda-dist`` -- ``environment: anaconda`` * Vendor Update's ``vendor-update`` -- ``environment: conda-tag`` -``conda-dist`` also has a ``windows-latest`` leg, which builds and smoke-tests a -``win-64`` package and publishes nothing -(`#2060 `__). Every upload -step is gated off it, and those are the only steps that read ``ANACONDA_TOKEN``, -so no step on that leg receives the secret. -:file:`tests/project/test_conda_windows_leg_2060.py` checks both. Each step that -can fail there continues on error, and ``Windows summary`` reports each failure -as a warning, as ``KTLO summary`` does for a KTLO leg, so the leg stays green. +Both ``conda-dist`` and the release ``conda`` job have a ``windows-latest`` leg, +which builds, smoke-tests and publishes the ``win-64`` package (:issue:`2060`). +It blocks as the other legs do: no step continues on error. Its one step that +reads ``ANACONDA_TOKEN`` is ``Upload conda packages to Anaconda (Windows)``, the +Intel leg's anaconda-client call (:issue:`2044`) under ``bash -el``, which skips a +manual run as every upload does. +:file:`tests/project/test_conda_windows_leg_2060.py` pins the leg. A required reviewer on ``conda-tag`` or ``anaconda`` would therefore hold every scheduled bump for an approval, besides the release-graph reason diff --git a/tests/project/test_conda_completeness_counts_every_leg_1922.py b/tests/project/test_conda_completeness_counts_every_leg_1922.py index fff1ed3bba..fe0831e02b 100644 --- a/tests/project/test_conda_completeness_counts_every_leg_1922.py +++ b/tests/project/test_conda_completeness_counts_every_leg_1922.py @@ -53,7 +53,7 @@ def published() -> 'set[str]': """The platform each ``conda`` leg publishes, from its matrix and the script its steps run (#1967).""" spec = jobs()['conda'] runs = [str(step.get('run', '')) for step in spec['steps'] if 'util/release_conda.py' in str(step.get('run', ''))] - assert len(runs) == 3, runs # the leg check, the build and the Intel upload + assert len(runs) == 4, runs # the leg check, the build, and the Intel and Windows uploads (#2060) return {release_conda.PLATFORMS[label] for label in spec['strategy']['matrix']['os']} @@ -74,7 +74,7 @@ class TheCheckRunsAsTheWorkflowRunsIt(unittest.TestCase): 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']) + self.assertEqual(sorted(step['env']), ['PCAPKIT_CONDA_BUILDS', 'PCAPKIT_TAG_EXISTS', 'PCAPKIT_VERSION']) with tempfile.TemporaryDirectory(prefix='pcapkit-1922-') as tmp: scratch = pathlib.Path(tmp) reply = scratch / 'reply.json' @@ -90,7 +90,8 @@ def complete(self, distributions: 'list[tuple[str, str]]') -> 'tuple[str, str]': result = subprocess.run( # nosec: B603 [sys.executable, str(ROOT / words[1]), *words[2:]], 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)), + GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=' '.join(BUILDS), + PCAPKIT_TAG_EXISTS='false'), 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()) diff --git a/tests/project/test_conda_intel_cli_upload_2044.py b/tests/project/test_conda_intel_cli_upload_2044.py index 61e97ddaab..1e3d6a9946 100644 --- a/tests/project/test_conda_intel_cli_upload_2044.py +++ b/tests/project/test_conda_intel_cli_upload_2044.py @@ -129,7 +129,7 @@ def test_the_intel_leg_runs_the_cli_step_only(self) -> None: self.assertTrue(runs(step(name, job_id, CLI_STEP), INTEL)) def test_every_other_leg_runs_the_action_only(self) -> None: - # #2060: but the Windows leg, which runs neither; test_conda_windows_leg_2060 pins that. + # #2060: but the Windows leg, whose own CLI step test_conda_windows_leg_2060 pins. for name, job_id in JOBS: others = [os for os in legs(name, job_id) if os not in (INTEL, 'windows-latest')] with self.subTest(workflow=name, job=job_id): diff --git a/tests/project/test_conda_native_platforms_1822.py b/tests/project/test_conda_native_platforms_1822.py index ee30d78c4e..9bd0d0228f 100644 --- a/tests/project/test_conda_native_platforms_1822.py +++ b/tests/project/test_conda_native_platforms_1822.py @@ -5,8 +5,7 @@ :file:`util/release_conda.py`'s ``PLATFORMS`` since #1967, 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 - build exactly :data:`conda_vendor.PLATFORMS`; win-64 only in Conda Update, - which does not publish it yet (#2060). + build exactly :data:`conda_vendor.PLATFORMS`, win-64 included (#2060). * 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. @@ -68,10 +67,10 @@ #: 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'} -#: Each job's legs: Conda Update's add #2060's unpublished ``windows-latest``. -JOB_LEGS = {('cron-conda.yml', 'conda-dist'): dict(LEGS, **{'windows-latest': 'win-64'}), - ('create-release.yml', 'conda'): LEGS} +LEGS = {'macos-latest': 'osx-arm64', 'macos-26-intel': 'osx-64', 'ubuntu-latest': 'linux-64', + 'windows-latest': 'win-64'} +#: Each job's legs, the same since #2060 gave the release job its Windows one. +JOB_LEGS = {('cron-conda.yml', 'conda-dist'): LEGS, ('create-release.yml', 'conda'): LEGS} #: ``platform.machine()``, as the runner-images table names the architecture. ARCH = {'x86_64': 'x64', 'arm64': 'arm64', 'AMD64': 'x64'} diff --git a/tests/project/test_conda_no_windows_steps_1967.py b/tests/project/test_conda_no_windows_steps_1967.py index ee8f2a1102..81419e73e7 100644 --- a/tests/project/test_conda_no_windows_steps_1967.py +++ b/tests/project/test_conda_no_windows_steps_1967.py @@ -13,8 +13,8 @@ #1967, maps no label that neither job has a leg on, and, with no Windows leg, no step is Windows-only. So adding a Windows leg back means adding its steps back deliberately, rather than - reviving ones nobody has run. #2060 did so for ``conda-dist``, whose Windows - steps are therefore live; ``conda`` still has no Windows leg. + reviving ones nobody has run. #2060 did so for both jobs, whose Windows + steps are therefore live. * :class:`TestTheCheckSeesDeadCode` feeds :func:`dead` the jobs as they stood before, rebuilt from the current ones, so the pin cannot pass by reading nothing, and ``conda-dist`` without its Windows leg, whose steps must then read @@ -144,17 +144,18 @@ def test_it_reports_a_windows_step_guard_and_arm(self) -> None: 'Map the leg: `case` arm windows-latest, which is no leg', ]) - def test_dropping_conda_dists_windows_leg_leaves_its_steps_dead(self) -> None: - """#2060's steps are live only while the leg is there.""" - spec = copy.deepcopy(_workflows.job('cron-conda.yml', 'conda-dist')) - spec['strategy']['matrix']['os'].remove('windows-latest') - found = dead(spec, legs() - {'windows-latest'}) - for line in ('Upload conda packages to Anaconda: `if:` compares matrix.os with windows-latest, which is no leg', - 'Install and Setup: `continue-on-error:` compares matrix.os with windows-latest, which is no leg', - 'Windows summary: Windows-only, and no leg is Windows', - 'Build conda packages: release_conda.py maps windows-latest, which no conda job has a leg on'): - with self.subTest(line=line): - self.assertIn(line, found) + def test_dropping_the_windows_legs_leaves_their_steps_dead(self) -> None: + """#2060's steps are live only while the legs are there.""" + for name, job_id in JOBS: + spec = copy.deepcopy(_workflows.job(name, job_id)) + spec['strategy']['matrix']['os'].remove('windows-latest') + found = dead(spec, legs() - {'windows-latest'}) + for line in ( + 'Upload conda packages to Anaconda: `if:` compares matrix.os with windows-latest, which is no leg', + 'Upload conda packages to Anaconda (Windows): Windows-only, and no leg is Windows', + 'Build conda packages: release_conda.py maps windows-latest, which no conda job has a leg on'): + with self.subTest(workflow=name, job=job_id, line=line): + self.assertIn(line, found) def test_a_leg_the_script_does_not_map_is_reported(self) -> None: spec = copy.deepcopy(_workflows.job('create-release.yml', 'conda')) diff --git a/tests/project/test_conda_windows_leg_2060.py b/tests/project/test_conda_windows_leg_2060.py index 1bee8a4027..9664f8e8a8 100644 --- a/tests/project/test_conda_windows_leg_2060.py +++ b/tests/project/test_conda_windows_leg_2060.py @@ -1,18 +1,22 @@ # -*- coding: utf-8 -*- -"""#2060, phase 1: Conda Update builds and smoke-tests win-64, and publishes none of it. - -``conda-dist`` in :file:`.github/workflows/cron-conda.yml` gained a -``windows-latest`` leg. ``create-release.yml``'s ``conda`` job has none yet; -phase 2 adds it, with the uploads. - -* :class:`TestTheWindowsLegPublishesNothing`: every step reading a secret or - publishing is evaluated on ``windows-latest`` with - :func:`tests.project.test_release_gates.evaluate_condition` and must not run. - So no step on that leg receives ``ANACONDA_TOKEN``, which the job's - ``environment: anaconda`` holds. -* :class:`TestTheWindowsLegNeverBlocks`: each step that runs there has an ``id`` - and a ``continue-on-error`` true on that leg alone, and ``Windows summary``, - the last step, reports their failures as warnings, as ``KTLO summary`` does (#1672). +"""#2060: the conda jobs build, smoke-test and publish win-64. + +Phase 1 gave ``conda-dist`` in :file:`.github/workflows/cron-conda.yml` a +non-blocking ``windows-latest`` leg that published nothing. Once its Conda +Update runs were green, phase 2 gave ``conda`` in +:file:`.github/workflows/create-release.yml` the leg too, made both blocking, and +uploads from both with the anaconda-client CLI, as the Intel legs do (#2044). + +* :class:`TestTheWindowsLegPublishes`: on ``windows-latest`` each job runs one + upload, "Upload conda packages to Anaconda (Windows)", evaluated with + :func:`tests.project.test_release_gates.evaluate_condition`; it is the Intel + step's call in a login shell, and skips a manual run and a leg already on + Anaconda. :data:`release_published.SUBDIRS` counts the platform. +* :class:`TestTheWindowsLegBlocks`: no step continues on error and no + ``Windows summary`` reports, so a failure fails the leg as it does the others; + each ``run:`` the leg reaches names ``bash``. +* :class:`TestTheWindowsClient`: ``release_conda.py upload`` runs base's + ``Scripts\\anaconda.exe`` on Windows. * :class:`TestBldBat`: ``conda/pypcapkit/bld.bat`` does what ``build.sh`` does: one install of every downloaded file, then the project, each checked. Its previous ``FOR`` loop wrote ``%A``, a syntax error in a batch file. @@ -24,7 +28,7 @@ where :data:`os.environb` is missing, as on Windows (#2120), where ``release_conda.py build`` died reading ``PCAPKIT_CONDA_OS``. -Nothing here runs on Windows; what a real Conda Update run shows is the rest. +Nothing here runs on Windows; what a real run shows is the rest. """ @@ -38,7 +42,6 @@ import os import pathlib import re -import subprocess # nosec: B404 import sys import tarfile import tempfile @@ -49,7 +52,7 @@ from tests import _workflows from tests.project.test_conda_native_platforms_1822 import DISTRIBUTION, SHIM, elf, load, pe from tests.project.test_release_gates import PUBLISHING_MARKERS, STATUS, evaluate_condition -from util import _actions, release_conda, release_tiers +from util import _actions, leg_summary, release_conda, release_published, release_tiers if TYPE_CHECKING: from typing import Any, Iterator @@ -59,144 +62,219 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] RECIPE = ROOT / 'conda' / 'pypcapkit' -JOB = ('cron-conda.yml', 'conda-dist') +#: The conda jobs, as (workflow, job). +JOBS = (('cron-conda.yml', 'conda-dist'), ('create-release.yml', 'conda')) WINDOWS = 'windows-latest' -SUMMARY = 'Windows summary' +INTEL = 'macos-26-intel' +#: The Windows leg's upload, and the Intel one it copies. +WINDOWS_UPLOAD = 'Upload conda packages to Anaconda (Windows)' +INTEL_UPLOAD = 'Upload conda packages to Anaconda (Intel)' #: The steps that read ``ANACONDA_TOKEN``: the uploads, and nothing else. -ANACONDA_UPLOADS = ['Upload conda packages to Anaconda', 'Upload conda packages to Anaconda (Intel)'] -#: What publishes besides :data:`PUBLISHING_MARKERS`: the GitHub Release upload. +ANACONDA_UPLOADS = ['Upload conda packages to Anaconda', INTEL_UPLOAD, WINDOWS_UPLOAD] +#: What publishes besides :data:`PUBLISHING_MARKERS`: Conda Update's GitHub Release upload. MORE_MARKERS = ('ncipollo/release-action',) +#: The step that ran phase 1's warnings. +SUMMARY = 'Windows summary' + + +def spec(name: 'str', job_id: 'str') -> 'dict[str, Any]': + return _workflows.job(name, job_id) -def spec() -> 'dict[str, Any]': - return _workflows.job(*JOB) +def step(name: 'str', job_id: 'str', step_name: 'str') -> 'dict[str, Any]': + found = [entry for entry in spec(name, job_id)['steps'] if entry.get('name') == step_name] + if len(found) != 1: + raise AssertionError(f'{name}: {job_id} has {len(found)} steps named {step_name!r}, not one') + return found[0] -def context(os_label: 'str', event_name: 'str' = 'schedule') -> 'dict[str, object]': - """A leg on ``os_label`` where every earlier step succeeded and the release exists. +def context(job: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', + published: 'str' = 'false') -> 'dict[str, object]': + """A leg on ``os_label`` where every earlier step succeeded and the GitHub release exists. On the schedule unless ``event_name`` says otherwise: a manual run uploads nothing on any leg, which :mod:`tests.project.test_build_only_dispatch` pins. + ``published`` is what the release job's ``check_leg`` found. """ - steps = {step['id']: {'outcome': 'success', 'conclusion': 'success', - 'outputs': {'published': 'true'}} - for step in spec()['steps'] if 'id' in step} + steps = {entry['id']: {'outcome': 'success', 'conclusion': 'success', + 'outputs': {'published': 'true', 'PCAPKIT_LEG_EXISTS': published}} + for entry in job['steps'] if 'id' in entry} return {'matrix': {'os': os_label}, 'steps': steps, 'github': {'event_name': event_name}, STATUS: {'cancelled': False, 'success': True, 'failure': False}} -def runs(step: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule') -> 'bool': - return evaluate_condition(str(step.get('if', 'true')), context(os_label, event_name)) +def runs(job: 'dict[str, Any]', entry: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', + published: 'str' = 'false') -> 'bool': + return evaluate_condition(str(entry.get('if', 'true')), context(job, os_label, event_name, published)) -def sensitive(step: 'dict[str, Any]') -> 'bool': - """Whether ``step`` reads a secret or publishes anything.""" - text = repr(step) +def sensitive(entry: 'dict[str, Any]') -> 'bool': + """Whether ``entry`` reads a secret or publishes anything.""" + text = repr(entry) return 'secrets.' in text or any(marker in text for marker in (*PUBLISHING_MARKERS, *MORE_MARKERS)) -def windows_publishers(job: 'dict[str, Any]') -> 'list[str]': - """Each step of ``job`` that reads a secret or publishes, and would run on the Windows leg.""" - return [str(step.get('name') or step.get('uses')) for step in job['steps'] - if sensitive(step) and runs(step, WINDOWS)] +def publishers(job: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', + published: 'str' = 'false') -> 'list[str]': + """Each step of ``job`` that reads a secret or publishes, and would run on ``os_label``.""" + return [str(entry.get('name') or entry.get('uses')) for entry in job['steps'] + if sensitive(entry) and runs(job, entry, os_label, event_name, published)] -class TestTheWindowsLegPublishesNothing(unittest.TestCase): - """No step that publishes, or reads a secret, runs on ``windows-latest``.""" +class TestTheWindowsLegPublishes(unittest.TestCase): + """Each conda job uploads win-64 from its Windows leg, with the anaconda-client CLI.""" - def test_the_leg_exists_in_conda_update_alone(self) -> None: - self.assertIn(WINDOWS, spec()['strategy']['matrix']['os']) - # Phase 2's: the release job gets the leg with its uploads. - self.assertNotIn(WINDOWS, _workflows.job('create-release.yml', 'conda')['strategy']['matrix']['os']) + def test_both_jobs_have_the_leg(self) -> None: + for name, job_id in JOBS: + with self.subTest(workflow=name, job=job_id): + self.assertIn(WINDOWS, spec(name, job_id)['strategy']['matrix']['os']) + self.assertEqual(release_conda.PLATFORMS[WINDOWS], 'win-64') - def test_no_secret_or_upload_reaches_the_windows_leg(self) -> None: - self.assertEqual(windows_publishers(spec()), []) + def test_the_windows_leg_runs_its_own_upload_alone(self) -> None: + """On a scheduled bump or a release, so the GitHub Release takes no win-64 file (as none of macOS's).""" + for name, job_id in JOBS: + with self.subTest(workflow=name, job=job_id): + self.assertEqual(publishers(spec(name, job_id), WINDOWS), [WINDOWS_UPLOAD]) + + def test_no_other_leg_runs_it(self) -> None: + for name, job_id in JOBS: + job = spec(name, job_id) + for os_label in job['strategy']['matrix']['os']: + with self.subTest(workflow=name, job=job_id, os=os_label): + self.assertEqual(runs(job, step(name, job_id, WINDOWS_UPLOAD), os_label), os_label == WINDOWS) + self.assertEqual(sum(runs(job, step(name, job_id, upload), os_label) + for upload in ANACONDA_UPLOADS), 1) + + def test_a_manual_conda_update_run_publishes_nothing_there(self) -> None: + job = spec('cron-conda.yml', 'conda-dist') + self.assertEqual(publishers(job, WINDOWS, event_name='workflow_dispatch'), []) + self.assertEqual(job['environment'], "${{ github.event_name != 'workflow_dispatch' && 'anaconda' || '' }}") + + def test_a_leg_already_on_anaconda_skips_it(self) -> None: + """#888's per-leg check gates the Windows upload as it does the others.""" + job = spec('create-release.yml', 'conda') + self.assertEqual(publishers(job, WINDOWS, published='true'), []) + self.assertEqual(job['environment'], 'anaconda') + check = next(entry for entry in job['steps'] if entry.get('id') == 'check_leg') + self.assertEqual(check['env']['PCAPKIT_CONDA_OS'], '${{ matrix.os }}') def test_only_the_uploads_read_the_anaconda_token(self) -> None: - readers = [step.get('name') for step in spec()['steps'] if 'secrets.ANACONDA_TOKEN' in repr(step)] - self.assertEqual(readers, ANACONDA_UPLOADS) - self.assertNotIn('ANACONDA_TOKEN', repr({key: value for key, value in spec().items() if key != 'steps'})) - - def test_every_other_leg_still_uploads(self) -> None: - """So the pin cannot pass by gating every upload off every leg.""" - steps = [step for step in spec()['steps'] if step.get('name') in ANACONDA_UPLOADS] - for os_label in spec()['strategy']['matrix']['os']: - if os_label == WINDOWS: - continue - with self.subTest(os=os_label): - self.assertEqual(sum(runs(step, os_label) for step in steps), 1) - - def test_the_check_sees_an_ungated_upload(self) -> None: - job = copy.deepcopy(spec()) - upload = next(step for step in job['steps'] if step.get('name') == ANACONDA_UPLOADS[0]) - upload['if'] = upload['if'].replace(f" && matrix.os != '{WINDOWS}'", '') - self.assertEqual(windows_publishers(job), [ANACONDA_UPLOADS[0]]) - - -class TestTheWindowsLegNeverBlocks(unittest.TestCase): - """Each step that can fail on the leg continues, and the summary reports it.""" - - def test_each_step_it_runs_continues_on_error_there_alone(self) -> None: - steps = spec()['steps'] - self.assertEqual(steps[-1].get('name'), SUMMARY) - others = [os_label for os_label in spec()['strategy']['matrix']['os'] if os_label != WINDOWS] - # A manual run's own steps, which build the dispatched commit, as well. - for event_name, step in ((event_name, step) for event_name in ('schedule', 'workflow_dispatch') - for step in steps[:-1]): - if not runs(step, WINDOWS, event_name): - continue - name = step.get('name') or step.get('uses') - with self.subTest(event=event_name, step=name): - self.assertIn('id', step) - guard = str(step.get('continue-on-error')) - self.assertTrue(evaluate_condition(guard, context(WINDOWS))) - for os_label in others: - self.assertFalse(evaluate_condition(guard, context(os_label)), os_label) - - def test_the_summary_runs_on_the_leg_alone_and_last(self) -> None: - summary = spec()['steps'][-1] - self.assertIs(summary.get('continue-on-error'), True) - self.assertEqual(summary.get('shell'), 'bash') - self.assertEqual(summary['env']['STEPS'], '${{ toJSON(steps) }}') - self.assertEqual(summary['run'], 'python util/leg_summary.py conda-windows') - for os_label in spec()['strategy']['matrix']['os']: - with self.subTest(os=os_label): - self.assertEqual(runs(summary, os_label), os_label == WINDOWS) - cancelled = dict(context(WINDOWS), **{STATUS: {'cancelled': True, 'success': False, 'failure': False}}) - self.assertFalse(evaluate_condition(summary['if'], cancelled)) - - def test_the_summary_warns_per_failed_step(self) -> None: - with tempfile.TemporaryDirectory() as temp: - page = pathlib.Path(temp) / 'summary.md' - result = subprocess.run( # nosec: B603 - [sys.executable, str(ROOT / 'util' / 'leg_summary.py'), 'conda-windows'], - env=dict(os.environ, LEG='conda-dist on windows-latest', GITHUB_STEP_SUMMARY=str(page), - STEPS=json.dumps({'checkout': {'outcome': 'success'}, 'conda_build': {'outcome': 'failure'}})), - capture_output=True, text=True, check=False, timeout=60) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.splitlines(), [ - '::warning title=conda_build failed on conda-dist on windows-latest::The win-64 conda leg is ' - "unpublished and never blocks (#2060), so this job still succeeds; see the conda_build step's log."]) + for name, job_id in JOBS: + job = spec(name, job_id) + with self.subTest(workflow=name, job=job_id): + readers = [entry.get('name') for entry in job['steps'] if 'secrets.ANACONDA_TOKEN' in repr(entry)] + self.assertEqual(readers, ANACONDA_UPLOADS) + + def test_it_is_the_intel_call_in_a_login_shell(self) -> None: + """The Intel step's inputs, on conda's base ``python``, which only ``bash -el`` activates.""" + for name, job_id in JOBS: + names = [entry.get('name') for entry in spec(name, job_id)['steps']] + windows, intel = step(name, job_id, WINDOWS_UPLOAD), step(name, job_id, INTEL_UPLOAD) + with self.subTest(workflow=name, job=job_id): + self.assertEqual(windows['env'], intel['env']) + self.assertEqual(windows['shell'], 'bash -el {0}') + self.assertEqual(windows['run'], 'python util/release_conda.py upload') + self.assertEqual(windows['if'], intel['if'].replace(INTEL, WINDOWS)) + self.assertNotIn('uses', windows) + self.assertEqual(names.index(WINDOWS_UPLOAD), names.index(INTEL_UPLOAD) + 1) + + def test_the_check_sees_an_upload_gated_off_the_leg(self) -> None: + """So :meth:`test_the_windows_leg_runs_its_own_upload_alone` reads the gate, as phase 1 had it.""" + for name, job_id in JOBS: + job = copy.deepcopy(spec(name, job_id)) + job['steps'] = [entry for entry in job['steps'] if entry.get('name') != WINDOWS_UPLOAD] + with self.subTest(workflow=name, job=job_id): + self.assertEqual(publishers(job, WINDOWS), []) + + def test_the_completeness_check_counts_every_release_leg(self) -> None: + """``version_check`` expects each platform the release legs publish, win-64 among them.""" + legs = spec('create-release.yml', 'conda')['strategy']['matrix']['os'] + self.assertEqual(sorted(release_published.SUBDIRS.split()), + sorted(release_conda.PLATFORMS[os_label] for os_label in legs)) + self.assertIn('win-64', release_published.SUBDIRS.split()) + + +class TestTheWindowsLegBlocks(unittest.TestCase): + """A failure on the Windows leg fails it, as on every other leg.""" + + def test_no_step_continues_on_error(self) -> None: + for name, job_id in JOBS: + job = spec(name, job_id) + with self.subTest(workflow=name, job=job_id): + self.assertNotIn('continue-on-error', job) + self.assertEqual([entry.get('name') or entry.get('id') for entry in job['steps'] + if 'continue-on-error' in entry], []) + self.assertNotIn(SUMMARY, [entry.get('name') for entry in job['steps']]) + self.assertNotIn('conda-windows', leg_summary.REASONS) def test_the_bash_steps_name_their_shell(self) -> None: """A ``run:`` step's default shell on Windows is ``pwsh``.""" - for step in spec()['steps']: - if 'run' in step and runs(step, WINDOWS): - with self.subTest(step=step.get('name')): - self.assertTrue(str(step.get('shell', '')).startswith('bash')) + for name, job_id in JOBS: + job = spec(name, job_id) + for event_name in ('schedule', 'workflow_dispatch') if name == 'cron-conda.yml' else ('push',): + for entry in job['steps']: + if 'run' in entry and runs(job, entry, WINDOWS, event_name): + with self.subTest(workflow=name, step=entry.get('name'), event=event_name): + self.assertTrue(str(entry.get('shell', '')).startswith('bash')) + + def test_conda_runs_in_a_login_shell(self) -> None: + """Only ``bash -el`` has base active on Windows, so ``conda`` and base's ``python`` there.""" + for name, job_id in JOBS: + for entry in spec(name, job_id)['steps']: + text = str(entry.get('run', '')) + if re.search(r'^conda |release_conda\.py (?:build|upload)$', text, re.MULTILINE) \ + and entry.get('name') != INTEL_UPLOAD: + with self.subTest(workflow=name, step=entry.get('name')): + self.assertEqual(entry.get('shell'), 'bash -el {0}') def test_the_build_step_maps_the_leg_to_win_64(self) -> None: # #1967: util/release_conda.py maps it, which the step runs with the leg's label. - build = next(step for step in spec()['steps'] if step.get('id') == 'conda_build') - self.assertEqual(build['run'], 'python ../../util/release_conda.py build') - self.assertEqual(build['env']['PCAPKIT_CONDA_OS'], '${{ matrix.os }}') - self.assertEqual(release_conda.PLATFORMS[WINDOWS], 'win-64') + for name, job_id in JOBS: + build = next(entry for entry in spec(name, job_id)['steps'] if entry.get('id') == 'conda_build') + with self.subTest(workflow=name, job=job_id): + self.assertEqual(build['run'], 'python ../../util/release_conda.py build') + self.assertEqual(build['env']['PCAPKIT_CONDA_OS'], '${{ matrix.os }}') def test_the_smoke_test_runs_python_exe_on_windows(self) -> None: - smoke = next(step for step in spec()['steps'] if step.get('id') == 'smoke') - self.assertIn('if [ "$RUNNER_OS" = Windows ]; then\n python="$(cygpath -u "$pythonLocation")/python.exe"\n', - smoke['run']) + for name, job_id in JOBS: + smoke = step(name, job_id, 'Smoke-test the vendored packages') + with self.subTest(workflow=name, job=job_id): + self.assertNotIn('if', smoke) + self.assertIn('if [ "$RUNNER_OS" = Windows ]; then\n' + ' python="$(cygpath -u "$pythonLocation")/python.exe"\n', smoke['run']) + + +class TestTheWindowsClient(unittest.TestCase): + """``release_conda.py upload`` runs base's ``anaconda``, wherever conda keeps it.""" + + PACKAGE = 'D:\\out\\win-64\\pypcapkit-0-py314_0.tar.bz2' + + def upload(self, windows: 'bool', conda: 'str') -> 'list[tuple[str, ...]]': + calls = [] # type: list[tuple[str, ...]] + + def call(*args: 'str') -> 'int': + calls.append(args) + return 0 + + environ = {'CONDA': conda, 'ANACONDA_API_TOKEN': 'TOKEN', 'PCAPKIT_CONDA_CHANNEL': 'jarryshaw', + 'PCAPKIT_CONDA_LABELS': 'main', 'PCAPKIT_CONDA_PACKAGES': self.PACKAGE} + with mock.patch.dict(os.environ, environ), mock.patch.object(release_conda, 'WINDOWS', windows), \ + mock.patch.object(release_conda, 'call', side_effect=call): + self.assertEqual(release_conda.main(['upload']), 0) + return calls + + def test_windows_runs_scripts_anaconda_exe(self) -> None: + calls = self.upload(True, 'C:\\Miniconda') + self.assertEqual(calls[0][0], 'conda') + self.assertEqual(calls[1], ('C:\\Miniconda\\Scripts\\anaconda.exe', '--token', 'TOKEN', 'upload', '--user', + 'jarryshaw', '--skip-existing', '--label', 'main', self.PACKAGE)) + + def test_elsewhere_it_runs_bin_anaconda(self) -> None: + self.assertEqual(self.upload(False, '/opt/miniforge')[1][0], '/opt/miniforge/bin/anaconda') + + def test_the_flag_reads_the_running_os(self) -> None: + self.assertIs(release_conda.WINDOWS, os.name == 'nt') #: A FOR loop's variable, with the percent signs before it. diff --git a/tests/project/test_ktlo_legs_stay_green_1672.py b/tests/project/test_ktlo_legs_stay_green_1672.py index 88a49890f6..804e2af43e 100644 --- a/tests/project/test_ktlo_legs_stay_green_1672.py +++ b/tests/project/test_ktlo_legs_stay_green_1672.py @@ -142,8 +142,6 @@ #: Why each summary's warning says the job still succeeds; the scripts differ in nothing else. KTLO_REASON = 'KTLO legs never block (#1633), so this job still succeeds (#1672)' EXPERIMENTAL_REASON = 'Experimental legs never block (#1660), so this job still succeeds' -#: #2060: cron-conda.yml's unpublished Windows leg, pinned in test_conda_windows_leg_2060.py. -CONDA_WINDOWS_REASON = 'The win-64 conda leg is unpublished and never blocks (#2060), so this job still succeeds' #: Each summary's command (#1967): the image's interpreter, not the leg's, which may #: have failed to set up. IMAGE_PYTHON = '/usr/bin/python3' @@ -735,8 +733,8 @@ def test_every_failure_it_masks_reaches_the_experimental_summary(self) -> None: # One script with the KTLO summary's, so a fix to either reaches both (#1967). self.assertEqual(self.summary['run'], EXPERIMENTAL_SUMMARY_RUN) self.assertEqual(summary_step(job('unit-tests.yml', 'test'))['run'], SUMMARY_RUN) - self.assertEqual(REASONS, {'ktlo': KTLO_REASON, 'experimental': EXPERIMENTAL_REASON, - 'conda-windows': CONDA_WINDOWS_REASON}) + # #2060: no `conda-windows` tier since the win-64 conda leg blocks as the others do. + self.assertEqual(REASONS, {'ktlo': KTLO_REASON, 'experimental': EXPERIMENTAL_REASON}) def test_each_step_names_the_leg_by_its_tier(self) -> None: """``LEG`` in an annotation's title: ``(experimental, non-blocking)``.""" diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index 0fe805854a..b706969010 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -231,11 +231,10 @@ 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. +#: The conda jobs' ``os`` axis since #1822, which added the Intel macOS runner, +#: and #2060, which added the Windows one. CONDA_JOBS = (('create-release.yml', 'conda'), ('cron-conda.yml', 'conda-dist')) -CONDA_OS = ['macos-latest', 'macos-26-intel', 'ubuntu-latest'] -#: The extra leg each conda job has beyond :data:`CONDA_OS`: #2060's unpublished Windows one. -CONDA_EXTRA_OS = {('create-release.yml', 'conda'): [], ('cron-conda.yml', 'conda-dist'): ['windows-latest']} +CONDA_OS = ['macos-latest', 'macos-26-intel', 'ubuntu-latest', 'windows-latest'] MAIN_MATRICES = { ('unit-tests.yml', 'test'): { 'python-version': SUPPORTED_AT_MAIN, @@ -636,8 +635,9 @@ def test_every_matrix_expands_to_mains_legs(self) -> 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 + CONDA_EXTRA_OS[(name, job_id)])) + # #1822: an Intel macOS leg per version, so osx-64 is built natively; + # #2060: and a Windows one, for win-64. + expected = legs(dict(spec, os=CONDA_OS)) expected = with_pypy(name, job_id, expected) if (name, job_id) in KTLO_TWINS: # #1738: the KTLO legs, in MAIN's order, are the twin job's. @@ -916,7 +916,8 @@ def complete(self, distributions: 'list[tuple[str, str]]') -> 'str': result = subprocess.run( [sys.executable, str(ROOT / words[1]), *words[2:]], 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=builds), + GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=builds, + PCAPKIT_TAG_EXISTS='false'), 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()) @@ -924,7 +925,8 @@ 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', 'osx-arm64') for build in builds] + every = [(platform, build) for platform in ('linux-64', 'osx-64', 'osx-arm64', 'win-64') + 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_conda_leg_smoke_1855.py b/tests/project/test_release_conda_leg_smoke_1855.py index bd36a4d211..6deaa5b24a 100644 --- a/tests/project/test_release_conda_leg_smoke_1855.py +++ b/tests/project/test_release_conda_leg_smoke_1855.py @@ -73,9 +73,8 @@ def test_the_smoke_test_runs_after_the_build_and_before_every_upload(self) -> No def test_it_is_conda_updates_step(self) -> None: """The same interpreter and package: setup-python's, ``host_package``. - #2060: Conda Update's step also finds that interpreter on its Windows - leg, and carries that leg's ``id``, gate and ``continue-on-error``. The - release job has no Windows leg yet, so its step is the POSIX branch alone. + #2060: both find that interpreter on their Windows legs too. Conda + Update's step alone has an ``id``, which its build-only run's pin reads. """ cron = steps(*CRON) @@ -86,9 +85,8 @@ def test_it_is_conda_updates_step(self) -> None: self.assertIn('"$python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE"', expected['run']) found = smoke_steps(release) self.assertEqual(len(found), 1, 'the release leg runs no smoke test') - self.assertEqual(release[found[0]], { - 'name': expected['name'], 'env': expected['env'], - 'run': '"${pythonLocation}/bin/python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE"\n'}) + self.assertEqual(expected['id'], 'smoke') + self.assertEqual(release[found[0]], {key: value for key, value in expected.items() if key != 'id'}) @unittest.skipIf(yaml is None, 'PyYAML, from the test extra, is not installed') diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 2cf3f3efa5..3ce893a24e 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -2442,7 +2442,7 @@ def test_pypi_evidence_reads_the_per_version_endpoint(self) -> None: def test_conda_evidence_checks_every_platform(self) -> None: self.assertIn(f'run: python3 {EVIDENCE_SCRIPT} anaconda ', self.jobs['version_check']) script = (ROOT / EVIDENCE_SCRIPT).read_text(encoding='utf-8') - self.assertIn("SUBDIRS = 'linux-64 osx-64 osx-arm64'", script) + self.assertIn("SUBDIRS = 'linux-64 osx-64 osx-arm64 win-64'", script) 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 f786b47e2f..57cac71830 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -38,7 +38,9 @@ other commit, which is likewise run, not just read; * once the version is tagged, ``version_check``'s PyPI and Anaconda counts are of the tiers at the tag's commit, not this run's (#1660), run against a - scratch repository, so a tier added since cannot fail the check above; + scratch repository, so a tier added since cannot fail the check above; and + its Anaconda count is of the platforms the tag's commit published (#2060), so + a version released before win-64 is not read as lacking it; * every step handed ``PCAPKIT_RUN_SHA`` is handed ``github.sha``, and no expression names ``workflow_run.head_sha`` -- the scratch-repository tests inject their own values, so these are what tie them to the job; @@ -73,6 +75,7 @@ from tests.project import test_release_gates as gates from tests.project.test_release_gates import (declared_environment, declared_if, evaluate_condition, evaluate_expression, job_blocks, strip_comments) +from util import release_published if TYPE_CHECKING: from typing import Any, Optional @@ -647,11 +650,14 @@ def test_conda_uploads_whatever_check_leg_found(self) -> None: already has is what stops the replacement #1640 measured. """ self.assertEqual(self.step('conda', UPLOAD_STEP).get('if'), "${{ matrix.os == 'ubuntu-latest' }}") - # #2044: each Anaconda upload also names its legs. + # #2044, #2060: each Anaconda upload also names its legs. self.assertEqual(self.step('conda', 'Upload conda packages to Anaconda').get('if'), - "${{ matrix.os != 'macos-26-intel' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }}") + "${{ matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' && " + "steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }}") self.assertEqual(self.step('conda', 'Upload conda packages to Anaconda (Intel)').get('if'), "${{ matrix.os == 'macos-26-intel' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }}") + self.assertEqual(self.step('conda', 'Upload conda packages to Anaconda (Windows)').get('if'), + "${{ matrix.os == 'windows-latest' && steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }}") class TestEveryScriptIsHandedTheRunSha(WorkflowMixin, unittest.TestCase): @@ -1236,8 +1242,9 @@ def complete(self, step: 'str', wheels: 'str', builds: 'str', reply: 'dict[str, (self.tmp / 'reply.json').write_text(json.dumps(reply), encoding='utf-8') (self.tmp / 'check.sh').write_text(script, encoding='utf-8') self.output.write_text('', encoding='utf-8') + # #2060: the platforms of an untagged version, which TestTheCountsAreOfTheReleasedPlatforms varies. env = dict(self.env, REPLY=str(self.tmp / 'reply.json'), PCAPKIT_VERSION=self.VERSION, - PCAPKIT_PYPI_WHEELS=wheels, PCAPKIT_CONDA_BUILDS=builds) + PCAPKIT_PYPI_WHEELS=wheels, PCAPKIT_CONDA_BUILDS=builds, PCAPKIT_TAG_EXISTS='false') # #1967: ``python3 util/release_published.py ...``, on this interpreter. command = script_command(script) or [BASH, '-e', str(self.tmp / 'check.sh')] result = subprocess.run(command, cwd=self.tmp, env=env, capture_output=True, @@ -1325,7 +1332,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', 'osx-arm64') + for platform in ('linux-64', 'osx-64', 'osx-arm64', 'win-64') for build in released['conda-builds'].split()]} step = 'Check PyPI publish completeness' self.assertEqual(self.complete(step, written['pypi-wheels'], '', pypi), 'true') @@ -1334,6 +1341,111 @@ def test_a_finished_release_reads_complete_after_the_tiers_grow(self) -> None: self.assertEqual(self.complete(step, '', written['conda-builds'], conda), 'true') +class TestTheCountsAreOfTheReleasedPlatforms(ScratchRepository): + """``version_check`` counts the conda platforms a tagged version was released for (#2060). + + ``pre`` is tagged before util/release_published.py existed, as every tag up to + v1.5.0b14 is; ``added`` adds it with the three platforms #1967 transcribed + from the step's shell; ``windows`` adds win-64, as #2060 did; the run is on + ``windows``. Counting this run's platforms for a version tagged earlier read + v1.5.0b14 as lacking win-64, and Release status warned that it was unfinished + on every run. + + """ + + VERSION = '1.5.0b14' + BUILDS = 'py311 py312' + BEFORE = 'linux-64 osx-64 osx-arm64' + STEP = 'Check Anaconda publish completeness' + + def setUp(self) -> None: + super().setUp() + self.bin = self.tmp / 'bin' + self.bin.mkdir() + (self.bin / 'curl').write_text(FAKE_CURL, encoding='utf-8') + (self.bin / 'curl').chmod(0o755) + self.env['PATH'] = f'{self.bin}{os.pathsep}{self.env["PATH"]}' + self.output = self.tmp / 'github-output' + self.env['GITHUB_OUTPUT'] = str(self.output) + self.pre = self.commit(self.VERSION, '0', 'before the script') + (self.seed / 'util').mkdir() + self.script = self.seed / 'util' / 'release_published.py' + self.script.write_text(f"SUBDIRS = '{self.BEFORE}'\n", encoding='utf-8') + self.added = self.commit(self.VERSION, '0', 'the script') + self.script.write_text(f"SUBDIRS = '{self.BEFORE} win-64'\n", encoding='utf-8') + self.windows = self.commit(self.VERSION, '0', 'win-64') + self.push(self.windows, 'refs/heads/main') + + def count(self, exists: 'bool', platforms: 'str') -> 'tuple[str, str]': + """``PCAPKIT_CONDA_COMPLETE`` and the log, on ``windows``, for a release of ``platforms``' builds.""" + reply = {'distributions': [{'attrs': {'subdir': platform, 'build': f'{build}_0'}} + for platform in platforms.split() for build in self.BUILDS.split()]} + (self.tmp / 'reply.json').write_text(json.dumps(reply), encoding='utf-8') + self.output.write_text('', encoding='utf-8') + run = self.step('version_check', self.STEP)['run'].replace('/tmp/anaconda_release.json', + str(self.tmp / 'release.json')) + env = dict(self.env, REPLY=str(self.tmp / 'reply.json'), PCAPKIT_VERSION=self.VERSION, + PCAPKIT_CONDA_BUILDS=self.BUILDS, PCAPKIT_TAG_EXISTS=str(exists).lower()) + command = script_command(run) + assert command is not None, run + result = subprocess.run(command, cwd=self.work, env=env, capture_output=True, text=True, check=False, + timeout=scale_timeout(60)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + written = dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) + return written['PCAPKIT_CONDA_COMPLETE'], result.stdout + + def test_the_step_is_handed_whether_the_tag_exists(self) -> None: + self.assertEqual(self.step('version_check', self.STEP)['env']['PCAPKIT_TAG_EXISTS'], + '${{ steps.check_tag.outputs.exists }}') + ids = [step.get('id') for step in self.steps['version_check']] + self.assertLess(ids.index('released'), ids.index('check_conda')) + + def test_the_line_read_at_a_tag_is_the_scripts(self) -> None: + """So rewording ``SUBDIRS`` fails here, not on a release.""" + text = (ROOT / release_published.SCRIPT).read_bytes() + self.assertEqual([found.group(1) for found in release_published.SUBDIRS_LINE.finditer(text)], + [release_published.SUBDIRS.encode()]) + + def test_before_the_tag_this_runs_platforms_count(self) -> None: + self.checkout(self.windows) + self.assertEqual(self.count(False, self.BEFORE)[0], 'false') + state, log = self.count(False, release_published.SUBDIRS) + self.assertEqual(state, 'true', log) + self.assertIn('found 8 of 8 expected', log) + + def test_a_tag_publishing_win_64_needs_it(self) -> None: + """A version released with win-64 that lacks it is unfinished, so a re-run of its run can finish it.""" + self.push(self.windows, f'refs/tags/v{self.VERSION}') + self.checkout(self.windows) + self.assertEqual(self.count(True, self.BEFORE)[0], 'false') + self.assertEqual(self.count(True, f'{self.BEFORE} win-64')[0], 'true') + + def test_a_tag_before_win_64_does_not_need_it(self) -> None: + self.push(self.added, f'refs/tags/v{self.VERSION}') + self.checkout(self.windows) + state, log = self.count(True, self.BEFORE) + self.assertEqual(state, 'true', log) + self.assertIn('found 6 of 6 expected', log) + self.assertNotIn('::warning', log) + # It still needs every platform it was released for. + self.assertEqual(self.count(True, 'linux-64 osx-arm64')[0], 'false') + + def test_a_tag_older_than_the_script_counts_the_platforms_it_was_added_with(self) -> None: + """As v1.5.0b14 (``6a9b666a1``) is: tagged before #1967 (``79934ddd9``) added the script.""" + self.push(self.pre, f'refs/tags/v{self.VERSION}') + self.checkout(self.windows) + state, log = self.count(True, self.BEFORE) + self.assertEqual(state, 'true', log) + self.assertIn('found 6 of 6 expected', log) + + def test_a_tag_it_cannot_read_counts_this_runs_platforms_and_warns(self) -> None: + self.checkout(self.windows) + state, log = self.count(True, self.BEFORE) + self.assertEqual(state, 'false') + self.assertIn(f'::warning title=Platforms of v{self.VERSION} not read (#2060)::', log) + self.assertIn('found 6 of 8 expected', log) + + @unittest.skipIf(not BASH, 'needs bash on PATH') class TestTheRefusalExplanation(WorkflowMixin, unittest.TestCase): """``github``'s "Explain a refused release tag", run as the job runs it.""" @@ -1623,13 +1735,15 @@ def test_the_check_reads_the_release_the_action_updates(self) -> None: self.assertNotIn('${{', check['run']) def test_the_anaconda_upload_is_not_gated_on_it(self) -> None: - # #2044: gated on the leg alone; #2060: Windows publishes nothing. + # #2044, #2060: gated on the leg alone, Intel and Windows with their own steps. # A manual run uploads nothing (tests/project/test_build_only_dispatch.py). self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda').get('if'), "${{ github.event_name != 'workflow_dispatch' && " "matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' }}") self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda (Intel)').get('if'), "${{ github.event_name != 'workflow_dispatch' && matrix.os == 'macos-26-intel' }}") + self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda (Windows)').get('if'), + "${{ github.event_name != 'workflow_dispatch' && matrix.os == 'windows-latest' }}") def test_an_update_targets_the_default_branch(self) -> None: """``github.ref_name`` is the default branch on this workflow's triggers. diff --git a/tests/project/test_release_version_check_scripts_1967.py b/tests/project/test_release_version_check_scripts_1967.py index 1195316890..1aa41ae5e9 100644 --- a/tests/project/test_release_version_check_scripts_1967.py +++ b/tests/project/test_release_version_check_scripts_1967.py @@ -257,7 +257,8 @@ def bash_version() -> 'str': set -euo pipefail # Each platform the ``conda`` legs publish, for each supported version. - export PCAPKIT_CONDA_SUBDIRS="linux-64 osx-64 osx-arm64" + # win-64 since the conda job's Windows leg publishes it (#2060). + export PCAPKIT_CONDA_SUBDIRS="linux-64 osx-64 osx-arm64 win-64" read -r -a subdirs <<< "$PCAPKIT_CONDA_SUBDIRS" read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" expected=$(( ${#subdirs[@]} * ${#builds[@]} )) @@ -432,8 +433,10 @@ def urls(*names: 'str') -> 'str': ) #: "Check Anaconda publish completeness"'s base, and its platforms. -ANACONDA_BASE = {'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_CONDA_BUILDS': 'py311 py312'} -PLATFORMS = ('linux-64', 'osx-64', 'osx-arm64') +#: #2060: untagged, as the former shell knew no tag; a tagged version's platforms are +#: test_release_tags_the_run_sha_1634.TestTheCountsAreOfTheReleasedPlatforms'. +ANACONDA_BASE = {'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_CONDA_BUILDS': 'py311 py312', 'PCAPKIT_TAG_EXISTS': 'false'} +PLATFORMS = ('linux-64', 'osx-64', 'osx-arm64', 'win-64') def distributions(*pairs: 'tuple[str, str]') -> 'str': @@ -450,7 +453,7 @@ def distributions(*pairs: 'tuple[str, str]') -> 'str': ('a platform missing', {}, distributions(*(pair for pair in EVERY if pair[0] != 'osx-64'))), ('osx-arm64 missing for one build', {}, distributions(*(pair for pair in EVERY if pair != ('osx-arm64', 'py312_0')))), - ('another platform is not counted', {}, distributions(*EVERY[1:], ('win-64', 'py311_0'))), + ('another platform is not counted', {}, distributions(*EVERY[1:], ('linux-aarch64', 'py311_0'))), ('a build without its underscore', {}, distributions(*EVERY[1:], ('linux-64', 'py311'))), ('a build of another tag', {}, distributions(*EVERY[1:], ('linux-64', 'py3110_0'))), ('no attrs', {}, '{"distributions": [{}]}'), @@ -467,8 +470,8 @@ def distributions(*pairs: 'tuple[str, str]') -> 'str': ('a body that is not json', {}, 'not json'), ('a distribution that is a string', {}, '{"distributions": ["x"]}'), ('no python3 at all', {'PATH': 'nopython3'}, distributions(*EVERY)), - ('a count that is not a number', {'COUNT_REPLY': '6.0'}, distributions()), - ('a count past what is expected', {'COUNT_REPLY': '7'}, distributions()), + ('a count that is not a number', {'COUNT_REPLY': '8.0'}, distributions()), + ('a count past what is expected', {'COUNT_REPLY': '9'}, distributions()), ('a version not in utf-8', {'PCAPKIT_VERSION': b'1.5\xff'}, distributions()), ('no version variable', {'PCAPKIT_VERSION': UNSET}, distributions(*EVERY)), ('no builds variable', {'PCAPKIT_CONDA_BUILDS': UNSET}, distributions(*EVERY)), @@ -746,10 +749,10 @@ def test_every_anaconda_branch_is_reached(self) -> None: "len(words(var('PCAPKIT_PYPI_WHEELS')))"), ('Check PyPI publish completeness', 'release_published.py', 'value >= expected', 'value > expected'), ('Check PyPI publish completeness', 'release_published.py', "value.split(b'\\n', 1)[0]", 'value'), - ('Check Anaconda publish completeness', 'release_published.py', "SUBDIRS = 'linux-64 osx-64 osx-arm64'", - "SUBDIRS = 'linux-64 osx-64'"), + ('Check Anaconda publish completeness', 'release_published.py', "SUBDIRS = 'linux-64 osx-64 osx-arm64 win-64'", + "SUBDIRS = 'linux-64 osx-64 osx-arm64'"), ('Check Anaconda publish completeness', 'release_published.py', - " os.environ['PCAPKIT_CONDA_SUBDIRS'] = SUBDIRS\n", ''), + " os.environ['PCAPKIT_CONDA_SUBDIRS'] = os.fsdecode(subdirs)\n", ''), ('Check Anaconda publish completeness', 'release_published.py', "ANACONDA_URL = 'https://api.anaconda.org/", "ANACONDA_URL = 'http://api.anaconda.org/"), ) diff --git a/util/conda_vendor.py b/util/conda_vendor.py index 50f8cb2159..521e196f8d 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -103,8 +103,7 @@ #: ``sys.platform``, ``platform.system()`` and ``platform.machine()`` read on its #: runner: ``ubuntu-latest``, the Intel ``macos-26-intel``, the arm64 #: ``macos-latest`` and ``windows-latest``. Each leg builds only its own runner's -#: platform (#1822). The Windows leg, ``cron-conda.yml``'s alone, builds and -#: smoke-tests with the recipe's ``bld.bat`` and publishes nothing yet (#2060). +#: platform (#1822). The Windows legs build with the recipe's ``bld.bat`` (#2060). PLATFORMS = { 'linux-64': ('linux', 'Linux', 'x86_64'), 'osx-64': ('darwin', 'Darwin', 'x86_64'), diff --git a/util/leg_summary.py b/util/leg_summary.py index 930e2ace96..fffbe8ba6f 100644 --- a/util/leg_summary.py +++ b/util/leg_summary.py @@ -11,11 +11,9 @@ /usr/bin/python3 util/leg_summary.py ktlo # or: experimental -:file:`.github/workflows/cron-conda.yml`'s ``Windows summary`` runs it as -``python util/leg_summary.py conda-windows``, since a Windows image has no -``/usr/bin/python3`` (#2060), and :file:`.github/workflows/experimental.yml`'s -``windows`` job runs its ``Experimental summary`` as ``python util/leg_summary.py -experimental`` for the same reason. +:file:`.github/workflows/experimental.yml`'s ``windows`` job runs its +``Experimental summary`` as ``python util/leg_summary.py experimental``, since a +Windows image has no ``/usr/bin/python3`` (#2060). with ``LEG``, the leg's display name, and ``STEPS``, ``${{ toJSON(steps) }}``, in the environment. ``STEPS`` holds each earlier step with an ``id``, and its @@ -53,7 +51,6 @@ REASONS = { 'ktlo': 'KTLO legs never block (#1633), so this job still succeeds (#1672)', 'experimental': 'Experimental legs never block (#1660), so this job still succeeds', - 'conda-windows': 'The win-64 conda leg is unpublished and never blocks (#2060), so this job still succeeds', } diff --git a/util/release_conda.py b/util/release_conda.py index 80cdea0b0b..c3e0ef97fe 100644 --- a/util/release_conda.py +++ b/util/release_conda.py @@ -25,11 +25,14 @@ the binaries unchanged), gets an ``::error`` and exits 1. ``python3 util/release_conda.py upload`` "Upload conda packages to Anaconda (Intel)" (#2044), on setup-python's - ``python3``. It installs conda-forge's anaconda-client into base and runs the + ``python3``, and as ``python util/release_conda.py upload`` "Upload conda + packages to Anaconda (Windows)" (#2060), under ``bash -el`` on conda's base + ``python``. It installs conda-forge's anaconda-client into base and runs the call ``anaconda/actions/upload-package`` makes, ``anaconda --token … upload --user …``, with ``--skip-existing``: a ``--label`` for each of ``PCAPKIT_CONDA_LABELS``' comma-separated names, trimmed, then each line of ``PCAPKIT_CONDA_PACKAGES``. With no package it gets an ``::error`` and exits 1. + The ``anaconda`` it runs is base's, :func:`client`. :data:`PLATFORMS` maps each leg of either job to the platform it publishes. @@ -51,6 +54,7 @@ import argparse import fnmatch +import ntpath import os import stat # conda and anaconda, with fixed argument lists and no shell. @@ -68,11 +72,10 @@ if TYPE_CHECKING: from typing import Optional -__all__ = ['PLATFORMS', 'PATTERNS', 'leg_source', 'env', 'call', 'append', 'packages', 'leg', 'build', 'upload', - 'main'] +__all__ = ['PLATFORMS', 'PATTERNS', 'WINDOWS', 'leg_source', 'env', 'call', 'append', 'packages', 'client', 'leg', + 'build', 'upload', 'main'] -#: The conda platform each runner label builds and publishes (#1822). ``conda`` -#: has no Windows leg; ``conda-dist``'s builds win-64 and publishes nothing (#2060). +#: The conda platform each runner label builds and publishes (#1822, #2060). PLATFORMS = { 'macos-latest': 'osx-arm64', 'macos-26-intel': 'osx-64', @@ -81,6 +84,8 @@ } #: The names of the files ``conda build`` writes that are packages. PATTERNS = ('*.tar.bz2', '*.conda') +#: Whether this runs on Windows, where conda puts its entry points in ``Scripts``. +WINDOWS = os.name == 'nt' def leg_source(path: 'str', platform: 'str', py_tag: 'str') -> 'str': @@ -141,6 +146,13 @@ def packages(out_dir: 'str') -> 'list[str]': return sorted(found) +def client() -> 'str': + """anaconda-client's ``anaconda`` in base, at ``$CONDA``: ``Scripts\\anaconda.exe`` on Windows (#2060).""" + if WINDOWS: + return ntpath.join(env('CONDA'), 'Scripts', 'anaconda.exe') + return env('CONDA') + '/bin/anaconda' + + def leg(reply: 'str') -> 'int': """``PCAPKIT_LEG_EXISTS``: whether anaconda.org has this leg's distribution already (#888).""" platform = PLATFORMS.get(env('PCAPKIT_CONDA_OS'), '') @@ -203,7 +215,7 @@ def upload() -> 'int': if not files: _actions.annotate('error', 'No conda packages to upload', escape=False) return 1 - return call(env('CONDA') + '/bin/anaconda', '--token', env('ANACONDA_API_TOKEN'), 'upload', + return call(client(), '--token', env('ANACONDA_API_TOKEN'), 'upload', '--user', env('PCAPKIT_CONDA_CHANNEL'), '--skip-existing', *labels, *files) diff --git a/util/release_published.py b/util/release_published.py index f0de9a9610..4aff49a3ba 100644 --- a/util/release_published.py +++ b/util/release_published.py @@ -11,7 +11,7 @@ appends ``PCAPKIT_PYPI_COMPLETE`` to ``$GITHUB_OUTPUT``. ``python3 util/release_published.py anaconda /tmp/anaconda_release.json`` The step "Check Anaconda publish completeness". It expects a distribution - for each platform in :data:`SUBDIRS`, the ones the ``conda`` legs publish, + for each platform the version was released for, :func:`released_subdirs`, and each build in ``PCAPKIT_CONDA_BUILDS``, reads ``https://api.anaconda.org/release/jarryshaw/pypcapkit/``'s ``distributions`` and appends ``PCAPKIT_CONDA_COMPLETE``. @@ -57,11 +57,16 @@ if TYPE_CHECKING: from typing import Callable, Optional, Tuple, Union -__all__ = ['SUBDIRS', 'PYPI_URL', 'ANACONDA_URL', 'INDEXES', 'pypi_source', 'anaconda_source', 'Unbound', 'Failed', - 'var', 'words', 'fetch', 'count', 'integer', 'complete', 'check', 'pypi', 'anaconda', 'main'] +__all__ = ['SUBDIRS', 'SCRIPT', 'SUBDIRS_LINE', 'PYPI_URL', 'ANACONDA_URL', 'INDEXES', 'pypi_source', + 'anaconda_source', 'Unbound', 'Failed', 'var', 'words', 'fetch', 'count', 'integer', 'complete', 'check', + 'git', 'released_subdirs', 'pypi', 'anaconda', 'main'] -#: Each platform the ``conda`` legs publish, for each supported build (#1922). -SUBDIRS = 'linux-64 osx-64 osx-arm64' +#: Each platform the ``conda`` legs publish, for each supported build (#1922, #2060). +SUBDIRS = 'linux-64 osx-64 osx-arm64 win-64' +#: This script, whose :data:`SUBDIRS` line at a tag's commit names what that +#: version was released for (#2060), and that line. +SCRIPT = 'util/release_published.py' +SUBDIRS_LINE = re.compile(rb"^SUBDIRS = '([^'\n]*)'$", re.MULTILINE) #: The per-version endpoints, before the version. PYPI_URL = 'https://pypi.org/pypi/pypcapkit/' ANACONDA_URL = 'https://api.anaconda.org/release/jarryshaw/pypcapkit/' @@ -138,17 +143,18 @@ def words(value: 'bytes') -> 'list[bytes]': return [word for word in re.split(rb'[ \t]+', value.split(b'\n', 1)[0]) if word] -def command(args: 'list[Union[str, bytes]]', source: 'Optional[bytes]' = None) -> 'Tuple[int, bytes]': +def command(args: 'list[Union[str, bytes]]', source: 'Optional[bytes]' = None, + quiet: 'bool' = False) -> 'Tuple[int, bytes]': """Run ``args``, ``source`` on its standard input: its status as the shell reports it, and its output. - Its standard error goes where the script's does. A command missing from - ``PATH`` is status 127. + Its standard error goes where the script's does, or with ``quiet`` nowhere. + A command missing from ``PATH`` is status 127. """ sys.stdout.flush() try: result = subprocess.run([os.fsencode(arg) for arg in args], input=source, # nosec: B603 B607 - stdout=subprocess.PIPE, check=False) + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL if quiet else None, check=False) except FileNotFoundError: print(f'{os.fsdecode(args[0])}: command not found', file=sys.stderr, flush=True) return 127, b'' @@ -224,6 +230,42 @@ def check(index: 'str', expected: 'int', url: 'bytes', path: 'str', source: 'Cal return 0 +def git(*args: 'Union[str, bytes]') -> 'Optional[bytes]': + """``git args``' output without its trailing newlines, its errors dropped; :data:`None` where it fails.""" + status, output = command(['git', *args], quiet=True) + return None if status else output.rstrip(b'\n') + + +def released_subdirs() -> 'bytes': + """The platforms the version was released for, as its tag's commit lists them (#2060). + + Until ``PCAPKIT_TAG_EXISTS`` is ``true``, :data:`SUBDIRS`. Once the tag + ``v`` exists, the :data:`SUBDIRS_LINE` of :data:`SCRIPT` at + its commit, which "Read the released Python versions" fetched where the + checkout lacked it; a commit older than :data:`SCRIPT` reads the line the + script was added with, as that step reads the first tier file. So a version + is not expected to have a platform its release did not publish: no tag + before #2060 lists win-64. Where ``git`` fails or the line does not parse, + :data:`SUBDIRS`, with a ``::warning``. + + """ + if var('PCAPKIT_TAG_EXISTS') != b'true': + return SUBDIRS.encode() + tag = b'v' + var('PCAPKIT_VERSION') + script = SCRIPT.encode() + start = git('rev-parse', '-q', '--verify', b'refs/tags/' + tag + b'^{commit}') + if start is not None and git('cat-file', '-e', start + b':' + script) is None: + added = git('log', '--diff-filter=A', '--format=%H', '--', script) + start = added.rsplit(b'\n', 1)[-1] if added else None + text = git('show', start + b':' + script) if start else None + found = SUBDIRS_LINE.search(text) if text is not None else None + if found: + return found.group(1) + _actions.emit(b'::warning title=Platforms of ' + tag + b' not read (#2060)::' + script + + b"'s SUBDIRS did not read at that tag, so this counts this commit's: " + SUBDIRS.encode() + b'\n') + return SUBDIRS.encode() + + def pypi(path: 'str') -> 'int': """``PCAPKIT_PYPI_COMPLETE``: one wheel per supported leg, plus the sdist.""" expected = len(words(var('PCAPKIT_PYPI_WHEELS'))) + 1 @@ -231,10 +273,12 @@ def pypi(path: 'str') -> 'int': def anaconda(path: 'str') -> 'int': - """``PCAPKIT_CONDA_COMPLETE``: each platform the ``conda`` legs publish, for each supported version.""" + """``PCAPKIT_CONDA_COMPLETE``: each platform the version was released for, for each supported version.""" + builds = var('PCAPKIT_CONDA_BUILDS') + subdirs = released_subdirs() # Exported, as the step did, so curl and the snippet see it. - os.environ['PCAPKIT_CONDA_SUBDIRS'] = SUBDIRS - expected = len(words(SUBDIRS.encode())) * len(words(var('PCAPKIT_CONDA_BUILDS'))) + os.environ['PCAPKIT_CONDA_SUBDIRS'] = os.fsdecode(subdirs) + expected = len(words(subdirs)) * len(words(builds)) return check('anaconda', expected, ANACONDA_URL.encode() + var('PCAPKIT_VERSION'), path, anaconda_source)