From 1cf10db64c64836cdc432eb4573f8c5f1ce990db Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 10:58:09 -0400 Subject: [PATCH] ci(cron-conda): gate conda-tag and conda-dist on a dev-release version (#2458) Mirror create-release.yml's #2452 dev-release gate in cron-conda.yml, which has its own independent, schedule/workflow_run-reachable route to publishing with no check against __version__ carrying a PEP 440 .devN suffix. - conda-update's Get Version step computes PCAPKIT_DEV_RELEASE from packaging.version.Version(...).is_devrelease, deliberately not is_prerelease, which is also true for a real alpha/beta/rc this project has shipped and must not block. - conda-tag's `if:` adds the clause as a top-level conjunct; conda-dist's adds it inside its publishing branch only, so a build-only manual run still builds and smoke-tests a dev version without uploading it. - discussion's "Post the report to Discussions" step now tells "nothing pushed" apart from "a dev-release build was pushed but Tag/Upload were withheld on purpose", instead of silently dropping both lines; the stale "Both skip unless a build was pushed" comment is corrected. - Comments describing #2452's create-release.yml gate as already merged are reworded to "proposed ... (not yet merged)". - test_conda_dev_release_gate_2458.py gains two inputs (1.6.0rc1.dev2, 1.6.0.post1.dev0) that close two narrowed-predicate mutations a cross-review found surviving; test_cron_reports_2030.py gains coverage for the new report line, plus three rows closing round 3's remaining gaps -- CONDA_CHANGED=false with DEV_RELEASE=true, DEV_RELEASE=false, and DEV_RELEASE='' on a pushed build -- each confirmed to kill its own widened-predicate mutant of the explanation line's elif guard. actionlint 1.7.12 (shellcheck 0.10.0): zero findings. Both test files: 43 passed, 105 subtests passed, including the new report and mutation-closing cases; pylint clean on both files; mypy clean on test_cron_reports_2030.py (the 2458 file carries the same pre-existing attr-defined/unused-ignore pattern as sibling tests, not CI-gated). --- .github/workflows/cron-conda.yml | 51 ++- .../test_conda_dev_release_gate_2458.py | 333 ++++++++++++++++++ tests/project/test_cron_reports_2030.py | 38 ++ 3 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 tests/project/test_conda_dev_release_gate_2458.py diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 1a5c8ebf21..ed927941d2 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -322,6 +322,18 @@ jobs: esac # A build-only run builds whether or not anything changed. + # + # #2458: `PCAPKIT_DEV_RELEASE` is `is_devrelease`, not `is_prerelease` -- + # the latter is also `true` for a deliberately-tagged alpha/beta/rc this + # project has shipped for real (e.g. 1.5.0b14), and gating on it would + # silently block one. `is_devrelease` is `true` only for a PEP 440 + # dev-release suffix (X.Y.Z.devN), the work-in-progress signal `conda-tag` + # and `conda-dist` read below so that a scheduled or `workflow_run` + # trigger never tags or uploads a dev version with no human in the path. + # Independent of the equivalent `PCAPKIT_DEV_RELEASE` gate proposed for + # create-release.yml's `version_check` job in #2452 (not yet merged): + # this workflow has no `version_check` job to share that output with, + # so it is computed here too. - name: Get Version if: steps.bump.outputs.bump == 'true' || needs.mode.outputs.publishes != 'true' id: get_version @@ -330,6 +342,7 @@ jobs: { echo "PCAPKIT_VERSION=$(python -c 'import pcapkit; print(pcapkit.__version__)')" + echo "PCAPKIT_DEV_RELEASE=$(python -c 'from packaging.version import Version; import pcapkit; print(str(Version(pcapkit.__version__).is_devrelease).lower())')" echo "PCAPKIT_CONDA_LABEL=$(python -c 'from packaging.version import Version; import pcapkit; print("dev" if Version(pcapkit.__version__).is_prerelease else "main")')" echo "PCAPKIT_BUILD=$(cat conda/build)" } >> "$GITHUB_OUTPUT" @@ -432,6 +445,9 @@ jobs: # The commit Push Changes put on `main`, and the one `conda-tag` tags. CONDA_COMMIT: ${{ steps.push-changes.outputs.commit }} PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} + # #2458: read by `conda-tag` and `conda-dist` below, to block tagging and + # uploading a dev-release version. + PCAPKIT_DEV_RELEASE: ${{ steps.get_version.outputs.PCAPKIT_DEV_RELEASE }} PCAPKIT_CONDA_LABEL: ${{ steps.get_version.outputs.PCAPKIT_CONDA_LABEL }} PCAPKIT_BUILD: ${{ steps.get_version.outputs.PCAPKIT_BUILD }} PYTHON_CONDA: ${{ steps.python-versions.outputs.conda }} @@ -462,10 +478,17 @@ jobs: # outputs, is never tagged. Same shape as create-release.yml's `tag` job. # # Never on a build-only run, which pushed nothing to tag. + # + # #2458: nor on a dev-release version (`__version__` carrying a PEP 440 + # `.devN` suffix) -- a scheduled run or the `workflow_run` trigger above is + # enough to reach this job with no human in the path, which mirrors the + # equivalent gate proposed for create-release.yml's publishers in #2452 + # (not yet merged). if: >- !cancelled() && needs.mode.outputs.publishes == 'true' && needs.conda-update.result == 'success' && - needs.conda-update.outputs.CONDA_CHANGED == 'true' + needs.conda-update.outputs.CONDA_CHANGED == 'true' && + needs.conda-update.outputs.PCAPKIT_DEV_RELEASE != 'true' permissions: contents: write steps: @@ -533,11 +556,21 @@ jobs: # pins the tag, which names the pushed commit only then, and `conda-tag` # skips only when no bump was pushed. A build-only run, which never tags, # builds the dispatched commit with `conda-update`'s changes applied. + # + # #2458: the dev-release check is checked again here, inside the + # publishing branch only, so a build-only run still builds and + # smoke-tests a dev version -- it uploads nothing regardless. On the + # publishing path it is also redundant with `conda-tag`'s own gate, which + # already leaves `needs.conda-tag.result` at `'skipped'` rather than + # `'success'`; kept explicit anyway, mirroring how the equivalent gate + # proposed for create-release.yml's publishers in #2452 (not yet merged) + # has each read it rather than relying on one another's cascade. if: >- !cancelled() && needs.conda-update.result == 'success' && (needs.mode.outputs.publishes != 'true' || ( needs.conda-tag.result == 'success' && - needs.conda-update.outputs.CONDA_CHANGED == 'true' + needs.conda-update.outputs.CONDA_CHANGED == 'true' && + needs.conda-update.outputs.PCAPKIT_DEV_RELEASE != 'true' )) permissions: contents: write @@ -844,6 +877,11 @@ jobs: UPDATE_STATUS: ${{ needs.conda-update.outputs.status }} TAG_RESULT: ${{ needs.conda-tag.result }} DIST_RESULT: ${{ needs.conda-dist.result }} + # #2458: needed to tell "skipped, nothing was pushed" apart from + # "skipped, a build was pushed but it is a dev release" below. + CONDA_CHANGED: ${{ needs.conda-update.outputs.CONDA_CHANGED }} + DEV_RELEASE: ${{ needs.conda-update.outputs.PCAPKIT_DEV_RELEASE }} + PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} run: | if [ "$UPDATE_RESULT" = skipped ]; then status="$HEAD_RESULT" @@ -863,12 +901,19 @@ jobs: else status="${UPDATE_STATUS:-success}" fi - # Both skip unless a build was pushed. + # #2458: both used to skip only when nothing was pushed, so a + # pushed build always carried Tag/Upload lines too. Now a pushed + # dev-release build (`DEV_RELEASE` = 'true') also leaves both + # skipped, on purpose, so that case gets its own explanatory line + # below rather than silently dropping Tag/Upload with no reason + # given. if [ "$TAG_RESULT" != skipped ] && [ -f report/conda-report.md ]; then { echo "- Tag: $TAG_RESULT" echo "- Upload: $DIST_RESULT" } >> report/conda-report.md + elif [ "$CONDA_CHANGED" = true ] && [ "$DEV_RELEASE" = true ] && [ -f report/conda-report.md ]; then + echo "- Tag: skipped, and Upload: skipped -- ${PCAPKIT_VERSION} carries a PEP 440 dev-release suffix (.devN), so PCAPKIT_DEV_RELEASE withheld both deliberately" >> report/conda-report.md fi fi python3 util/discussion_report.py --category "$REPORT_CATEGORY" \ diff --git a/tests/project/test_conda_dev_release_gate_2458.py b/tests/project/test_conda_dev_release_gate_2458.py new file mode 100644 index 0000000000..d3cbd98ff1 --- /dev/null +++ b/tests/project/test_conda_dev_release_gate_2458.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +"""#2458: ``cron-conda.yml`` never tags or uploads a dev-release version. + +#2452 (not yet merged) proposes the equivalent gate for +:file:`.github/workflows/create-release.yml`'s publishers (``github``, ``tag``, +``pypi``, ``conda``), gated on ``PCAPKIT_DEV_RELEASE`` by that PR's owner +ruling: ``__version__`` carrying a PEP 440 dev-release suffix (``X.Y.Z.devN``) +is a work-in-progress signal, and that workflow's ``workflow_run`` trigger +means a scheduled Vendor Update can reach it and publish that version with no +human in the path. + +:file:`cron-conda.yml` is a separate workflow with its own independent route to +publishing: ``conda-tag`` pushes a ``conda-+`` tag and +``conda-dist`` uploads to anaconda.org, both whenever ``mode``'s ``publishes`` +output is ``'true'`` and ``conda-update``'s ``CONDA_CHANGED`` is ``'true'`` -- +reachable the same way, from its own ``schedule`` and ``workflow_run`` triggers, +and until now with no dev-release check of its own. This workflow has no +``version_check`` job to share #2452's proposed output with, so +``conda-update``'s own ``Get Version`` step computes ``PCAPKIT_DEV_RELEASE`` +independently, and ``conda-tag`` and ``conda-dist`` each read it. + +Deliberately ``is_devrelease``, never ``is_prerelease``: the latter is also +``true`` for a deliberately-tagged alpha/beta/rc this project has shipped for +real (e.g. ``1.5.0b14``), and gating on it would silently block one. +:meth:`TestGetVersionComputesDevRelease.test_running_the_script_disagrees_with_is_prerelease_on_a_real_release` +runs the real step script against both shapes of version string to show the +two outputs actually disagree there. + +* :class:`TestGetVersionComputesDevRelease`: the ``Get Version`` step's script, + and the job's declared ``outputs:``, by text and by running the script. +* :class:`TestTheGateIsAToplevelConjunct`: ``conda-tag`` and ``conda-dist`` + each read ``PCAPKIT_DEV_RELEASE`` exactly once, as a top-level ``&&`` + conjunct (``conda-dist``'s inside its publishing branch only, so a + build-only run still builds and smoke-tests a dev version). +* :class:`TestTheGateBlocksPublishing`: :func:`tests.project.test_release_gates + .job_runs` on dev-release rows built on top of + :func:`tests.project.test_release_gates.conda_context`: tagging and + uploading are blocked on the schedule and on a ``workflow_run``, a + build-only manual run still builds, and the gate costs a run nothing when + the output is absent (an older run's shape, served as ``null``). + +""" + +from __future__ import annotations + +import os +import pathlib +import re +import shutil +import subprocess # nosec: B404 +import tempfile +import unittest + +from tests import _workflows +from tests._support import bash_argv +from tests.project.test_release_gates import (NULL, UnsupportedExpression, conda_context, declared_if, + evaluate_condition, job_blocks, job_runs, resolved_context, + with_condition) + +#: Test tier (#1958): reads the workflow file, and runs one step's shell offline. +__tier__ = 'LATEST' + +CONDA = 'cron-conda.yml' +#: The ``bash`` the step runs under, by absolute path (#2103). +BASH = shutil.which('bash') +#: The one ``&&`` conjunct both publishing jobs must carry, compact (no whitespace). +CONJUNCT = "needs.conda-update.outputs.PCAPKIT_DEV_RELEASE!='true'" + + +def _step(job_id: 'str', label: 'str') -> 'dict[str, object]': + [found] = [entry for entry in _workflows.steps(CONDA, job_id) + if label in (entry.get('name'), entry.get('id'))] + return found # type: ignore[return-value] + + +def dev_context(dev_release: 'object', **kwargs: 'object') -> 'dict[str, object]': + """:func:`tests.project.test_release_gates.conda_context`, with ``conda-update``'s + ``Get Version`` step handed ``PCAPKIT_DEV_RELEASE`` as ``dev_release``. + + :data:`tests.project.test_release_gates.NULL` reproduces an older run's shape, + which published no such output at all; left out of :func:`conda_context` itself + rather than added as a parameter there, since nothing else in this module's + sibling tests needs it. + + """ + context = conda_context(**kwargs) # type: ignore[arg-type] + steps = context['needs']['conda-update']['steps'] # type: ignore[index] + assert isinstance(steps, dict) + if dev_release is NULL: + steps['get_version']['outputs'].pop('PCAPKIT_DEV_RELEASE', None) # type: ignore[index] + else: + steps['get_version']['outputs']['PCAPKIT_DEV_RELEASE'] = dev_release # type: ignore[index] + return context + + +class TestGetVersionComputesDevRelease(unittest.TestCase): + """The ``Get Version`` step in ``conda-update``, and what it hands downstream.""" + + @classmethod + def setUpClass(cls) -> None: + cls.step = _step('conda-update', 'Get Version') + + def test_the_script_reads_is_devrelease_not_is_prerelease(self) -> None: + script = self.step['run'] + assert isinstance(script, str) + self.assertIn('PCAPKIT_DEV_RELEASE=$(python -c ', script) + dev_release_line = next(line for line in script.splitlines() + if 'PCAPKIT_DEV_RELEASE=$(python -c' in line) + self.assertIn('Version(pcapkit.__version__).is_devrelease', dev_release_line) + # PCAPKIT_CONDA_LABEL (pre-existing) legitimately reads is_prerelease a + # few lines away; this narrows to the one line that must not. + self.assertNotIn('is_prerelease', dev_release_line) + + def test_conda_update_declares_the_output(self) -> None: + outputs = _workflows.job(CONDA, 'conda-update')['outputs'] + assert isinstance(outputs, dict) + self.assertEqual(outputs.get('PCAPKIT_DEV_RELEASE'), + '${{ steps.get_version.outputs.PCAPKIT_DEV_RELEASE }}') + + def test_the_step_runs_whenever_a_bump_or_a_build_only_run_needs_the_version(self) -> None: + """Same gate as ``PCAPKIT_VERSION`` and ``PCAPKIT_CONDA_LABEL``, which + ``conda-tag``/``conda-dist`` already depend on this step for. + + """ + self.assertEqual(self.step['if'], + "steps.bump.outputs.bump == 'true' || needs.mode.outputs.publishes != 'true'") + + @unittest.skipIf(not BASH, 'needs bash on PATH') + def test_running_the_script_disagrees_with_is_prerelease_on_a_real_release(self) -> None: + """The semantic reason ``is_prerelease`` is wrong here, run rather than asserted. + + ``1.5.0b14`` is a real, deliberately-tagged beta this project has + shipped (see the module docstring): ``is_prerelease`` is ``true`` for + it, ``is_devrelease`` is not. A dev build in progress + (``1.6.0.dev3``) is the opposite: both outputs the script writes + today agree it is a dev build, but ``PCAPKIT_DEV_RELEASE`` is the one + a mutation back to ``is_prerelease`` would flip silently on the beta. + + """ + for version, label, dev_release in ( + ('1.5.0b14', 'dev', 'false'), # is_prerelease: true, is_devrelease: false + ('1.6.0.dev3', 'dev', 'true'), # both true + ('1.6.0', 'main', 'false'), # both false + # A dev build that also carries a `pre` segment: closes a mutation + # that would narrow the script's predicate to + # `is_devrelease and Version(v).pre is None`, which would read + # false here although this is still a dev release. + ('1.6.0rc1.dev2', 'dev', 'true'), + # A dev build that also carries a `post` segment: closes a + # mutation that would narrow the predicate to + # `is_devrelease and not is_postrelease`, which would read false + # here for the same reason. + ('1.6.0.post1.dev0', 'dev', 'true'), + ): + with self.subTest(version=version): + outputs = self._run(version) + self.assertEqual(outputs['PCAPKIT_CONDA_LABEL'], label) + self.assertEqual(outputs['PCAPKIT_DEV_RELEASE'], dev_release) + + def _run(self, version: 'str') -> 'dict[str, str]': + """The step's script, against a scratch ``pcapkit`` stub reporting ``version``.""" + with tempfile.TemporaryDirectory(prefix='pcapkit-2458-get-version-') as tmp: + root = pathlib.Path(tmp) + (root / 'pcapkit.py').write_text(f'__version__ = {version!r}\n', encoding='utf-8') + (root / 'conda').mkdir() + (root / 'conda' / 'build').write_text('7\n', encoding='utf-8') + output = root / 'output' + output.write_text('', encoding='utf-8') + env = {'PATH': os.environ.get('PATH', ''), 'PYTHONPATH': str(root), + 'GITHUB_OUTPUT': str(output)} + script = self.step['run'] + assert isinstance(script, str) + result = subprocess.run( # nosec: B603 + bash_argv('-e', '-c', script, executable=str(BASH)), + cwd=root, env=env, capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + lines = dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').splitlines() if line) + return lines + + +class TestTheGateIsAToplevelConjunct(unittest.TestCase): + """The text layer: both publishing jobs read the new output exactly once, + and reversing or dropping the comparison is what the regex has to notice. + + """ + + def test_conda_tag_reads_it_as_a_toplevel_and(self) -> None: + condition = _workflows.job(CONDA, 'conda-tag')['if'] + assert isinstance(condition, str) + compact = re.sub(r'\s+', '', condition) + self.assertIn(f'&&{CONJUNCT}', compact, + '`conda-tag` does not gate on PCAPKIT_DEV_RELEASE as a top-level ' + '`&&` conjunct') + self.assertTrue(compact.endswith(CONJUNCT), compact) + self.assertEqual(compact.count('PCAPKIT_DEV_RELEASE'), 1) + + def test_conda_dist_reads_it_inside_the_publishing_branch_only(self) -> None: + condition = _workflows.job(CONDA, 'conda-dist')['if'] + assert isinstance(condition, str) + compact = re.sub(r'\s+', '', condition) + escape = "&&(needs.mode.outputs.publishes!='true'||(" + self.assertEqual(compact.count(escape), 1, compact) + self.assertTrue(compact.endswith('))'), compact) + inner = compact.replace(escape, '', 1)[:-2] + self.assertTrue(inner.endswith(f'&&{CONJUNCT}'), inner) + self.assertEqual(compact.count('PCAPKIT_DEV_RELEASE'), 1) + # Not a top-level conjunct of the whole expression -- a build-only run + # (the `publishes != 'true'` disjunct) must reach the build regardless + # of the version. + self.assertFalse(compact.startswith(f'{CONJUNCT}&&')) + + def test_every_declared_if_is_evaluable(self) -> None: + """The same bar :mod:`tests.project.test_release_gates` holds every + condition in this file to -- :func:`tests.project.test_release_gates + .evaluate_condition` implements a fixed subset, and + ``test_every_declared_if_is_evaluable`` there already covers every job + in ``cron-conda.yml``, this module's two included. Re-stated here so a + failure names this module rather than only the shared one. + + """ + text = (_workflows.WORKFLOWS / CONDA).read_text(encoding='utf-8') + jobs = job_blocks(text, _workflows.WORKFLOWS / CONDA) + for name in ('conda-tag', 'conda-dist'): + condition = declared_if(jobs[name]) + with self.subTest(job=name): + try: + evaluate_condition(condition, resolved_context(jobs, conda_context())) + except UnsupportedExpression as error: + self.fail(f'`{name}`\'s `if:` is outside the evaluated subset: {error}') + + +class TestTheGateBlocksPublishing(unittest.TestCase): + """:func:`tests.project.test_release_gates.job_runs`, on dev-release rows + built on top of :func:`tests.project.test_release_gates.conda_context`. + + """ + + @classmethod + def setUpClass(cls) -> None: + cls.jobs = job_blocks((_workflows.WORKFLOWS / CONDA).read_text(encoding='utf-8'), + _workflows.WORKFLOWS / CONDA) + + def test_a_dev_release_bump_is_neither_tagged_nor_uploaded(self) -> None: + """The run 38325136710 shape (:func:`conda_context`'s defaults), but for a + dev-release version: without the gate this is exactly #1632's chain, which + :mod:`tests.project.test_release_gates`'s + ``test_a_committed_bump_is_tagged_and_uploaded`` pins the other way. + + """ + context = dev_context('true') + self.assertTrue(job_runs(self.jobs, 'conda-update', context)) + for job in ('conda-tag', 'conda-dist'): + with self.subTest(job=job): + self.assertFalse( + job_runs(self.jobs, job, context), + f'`{job}` runs for a dev-release bump, so it would be tagged or ' + f'uploaded with no human in the path (#2458)') + + def test_a_dev_release_bump_on_the_schedule_is_also_blocked(self) -> None: + context = dev_context('true', event_name='schedule', unit_tests='success', verdict='skipped', + verified=NULL) + self.assertTrue(job_runs(self.jobs, 'conda-update', context)) + for job in ('conda-tag', 'conda-dist'): + with self.subTest(job=job): + self.assertFalse(job_runs(self.jobs, job, context)) + + def test_a_non_dev_bump_still_publishes(self) -> None: + """The gate costs a real release nothing: the bare row, with the output + explicitly ``'false'`` rather than merely absent. + + """ + context = dev_context('false') + for job in ('conda-update', 'conda-tag', 'conda-dist'): + with self.subTest(job=job): + self.assertTrue(job_runs(self.jobs, job, context)) + + def test_an_older_run_with_no_such_output_still_publishes(self) -> None: + """#2458 is additive: a row shaped like one from before this change existed + -- ``conda-update`` published no ``PCAPKIT_DEV_RELEASE`` at all, served as + ``null`` -- must not newly block a bump that was never a dev release. + + """ + context = dev_context(NULL) + for job in ('conda-tag', 'conda-dist'): + with self.subTest(job=job): + self.assertTrue(job_runs(self.jobs, job, context)) + + def test_a_build_only_dev_release_run_still_builds(self) -> None: + """A manual run without ``publish`` builds whatever ``conda-update`` left, + tags nothing, and must keep doing so for a dev-release version: there is + nothing to tag or upload on this path regardless, and blocking the build + would also lose the smoke test. + + """ + context = dev_context('true', event_name='workflow_dispatch', unit_tests='success', + verdict='skipped', verified=NULL, files_changed='false', pushed=NULL, + conda_tag='skipped') + self.assertTrue(job_runs(self.jobs, 'conda-update', context)) + self.assertFalse(job_runs(self.jobs, 'conda-tag', context)) + self.assertTrue( + job_runs(self.jobs, 'conda-dist', context), + '`conda-dist` does not build a dev-release version on a build-only ' + 'manual run, which uploads nothing regardless of the version') + + def test_removing_the_conjunct_reproduces_the_defect(self) -> None: + """The headline tests above have to be able to fail: with the clause + stripped from both jobs' ``if:``, a dev-release bump is tagged and + uploaded again. + + """ + jobs = dict(self.jobs) + for name in ('conda-tag', 'conda-dist'): + condition = declared_if(jobs[name]) + mutant = re.sub(r"\s*&&\s*needs\.conda-update\.outputs\.PCAPKIT_DEV_RELEASE\s*!=\s*'true'", + '', condition) + self.assertNotEqual(mutant, condition, + f'the mutation did not remove the clause from `{name}`, so this ' + f'test proves nothing') + self.assertNotIn('PCAPKIT_DEV_RELEASE', mutant, + f'the mutation did not remove the clause from `{name}`, so this ' + f'test proves nothing') + jobs[name] = with_condition(jobs[name], mutant) + context = dev_context('true') + for job in ('conda-tag', 'conda-dist'): + with self.subTest(job=job): + self.assertTrue( + job_runs(jobs, job, context), + f'`{job}` still skips with the PCAPKIT_DEV_RELEASE clause removed, so ' + f'this test would not have caught #2458 being reverted') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_cron_reports_2030.py b/tests/project/test_cron_reports_2030.py index 71b6f76135..4a0a06eb65 100644 --- a/tests/project/test_cron_reports_2030.py +++ b/tests/project/test_cron_reports_2030.py @@ -67,6 +67,11 @@ 'UPDATE_STATUS': '${{ needs.conda-update.outputs.status }}', 'TAG_RESULT': '${{ needs.conda-tag.result }}', 'DIST_RESULT': '${{ needs.conda-dist.result }}', + # #2458: tells "skipped, nothing pushed" apart from "skipped, a + # dev-release build was pushed on purpose". + 'CONDA_CHANGED': '${{ needs.conda-update.outputs.CONDA_CHANGED }}', + 'DEV_RELEASE': '${{ needs.conda-update.outputs.PCAPKIT_DEV_RELEASE }}', + 'PCAPKIT_VERSION': '${{ needs.conda-update.outputs.PCAPKIT_VERSION }}', }), VERSIONS: ('--mode scheduled --status "$status"', { 'DETECT_RESULT': '${{ needs.detect.result }}', @@ -526,6 +531,39 @@ def test_the_conda_status_and_the_tag_and_upload(self) -> None: UPDATE_RESULT=update, UPDATE_STATUS=status, TAG_RESULT=tag, DIST_RESULT=dist), (expected, 'the run\n' + added)) + def test_a_pushed_dev_release_skips_tag_and_upload_with_an_explanation(self) -> None: + """#2458: a pushed dev-release build leaves Tag/Upload skipped, but says why, not silently.""" + self.assertEqual(self.run_step(CONDA, 'the run\n', MODE='scheduled', HEAD_RESULT='success', + UPDATE_RESULT='success', UPDATE_STATUS='success', TAG_RESULT='skipped', + DIST_RESULT='skipped', CONDA_CHANGED='true', DEV_RELEASE='true', + PCAPKIT_VERSION='1.6.0.dev1'), ('success', 'the run\n' + '- Tag: skipped, and Upload: skipped -- 1.6.0.dev1 carries a PEP 440 dev-release suffix (.devN), ' + 'so PCAPKIT_DEV_RELEASE withheld both deliberately\n')) + + def test_a_skipped_tag_with_nothing_pushed_adds_no_explanation(self) -> None: + """The ordinary case -- `CONDA_CHANGED` and `DEV_RELEASE` both unset -- stays silent, as before #2458.""" + self.assertEqual(self.run_step(CONDA, 'the run\n', MODE='scheduled', HEAD_RESULT='success', + UPDATE_RESULT='success', UPDATE_STATUS='success', TAG_RESULT='skipped', + DIST_RESULT='skipped'), ('success', 'the run\n')) + + def test_no_explanation_unless_conda_changed_and_dev_release_both_hold(self) -> None: + """#2458: the explanation line needs `CONDA_CHANGED` and `DEV_RELEASE` both literally 'true' -- a dev + version that was not pushed, a pushed build that is not a dev release, and a pushed build with + `DEV_RELEASE` unset to empty, all stay as quiet as the ordinary skip above.""" + rows = ( + ('a dev version that was not pushed', 'success', + {'CONDA_CHANGED': 'false', 'DEV_RELEASE': 'true', 'PCAPKIT_VERSION': '1.6.0.dev1'}), + ('a pushed build that is not a dev release', 'cancelled', + {'CONDA_CHANGED': 'true', 'DEV_RELEASE': 'false'}), + ('DEV_RELEASE unset to empty on a pushed build', 'cancelled', + {'CONDA_CHANGED': 'true', 'DEV_RELEASE': ''}), + ) + for label, update, env in rows: + with self.subTest(label): + self.assertEqual(self.run_step(CONDA, 'the run\n', MODE='scheduled', HEAD_RESULT='success', + UPDATE_RESULT=update, UPDATE_STATUS='success', TAG_RESULT='skipped', + DIST_RESULT='skipped', **env), (update, 'the run\n')) + def test_the_versions_status(self) -> None: for result, status, expected in (('success', 'success', 'success'), ('success', 'warning', 'warning'), ('success', '', 'success'), ('failure', 'warning', 'failure'),