diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ddcfd4ff33..7899069817 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -123,6 +123,28 @@ concurrency: # gate and every publisher, and ``release_status`` reports, green, whether that # release is complete. A ``v*`` push still meets the #1634 check. The # ``vendor_update`` job is the issue's other half. +# +# Owner ruling, 2026-10-30: ``__version__`` carries a PEP 440 dev-release +# suffix (``X.Y.Z.devN``) while a version is still under active development, a +# work-in-progress signal with no gate of its own until now -- and the +# ``workflow_run`` trigger above means a scheduled Vendor Update can reach this +# workflow and carry that dev version all the way to ``github``'s one reviewer +# approval (#887, below). That approval is not nothing, but it is one person +# approving a diff for reasons that are usually unrelated to the version +# string, so a stray ``.devN`` is easy to miss there; and a re-run of a job +# that already skipped ``github`` once does not ask for a fresh approval at +# all. ``PCAPKIT_DEV_RELEASE`` closes that gap in code rather than leaving it +# to review. ``PCAPKIT_DEV_RELEASE`` (``version_check``'s ``is_devrelease``) +# is read by ``github``, ``tag``, ``pypi`` and ``conda`` alike, deliberately not +# ``PCAPKIT_PRERELEASE`` (``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. +# tests/project/test_release_gates.py's ``test_all_four_publishers_gate_on_dev_release`` +# checks this text, and ``gate_rows``/``GITHUB_ROWS`` evaluate it. ``release_status`` +# also reads it: with the gate skipping all four publishers, their evidence +# outputs stay ``false``, which would otherwise read as #888's stranded partial +# release. util/release_status.py's ``dev_release_skipped()`` reports that +# combination as a quiet, deliberate no-op instead. jobs: # #1748: a Vendor Update raised by a Unit Tests verdict for another commit @@ -226,6 +248,17 @@ jobs: run: | python -m pip install -U pip setuptools wheel packaging + # Owner ruling, 2026-10-30: during active development on a not-yet-released + # version, ``__version__`` carries a PEP 440 dev-release suffix + # (``X.Y.Z.devN``) as a work-in-progress signal. ``PCAPKIT_DEV_RELEASE`` is + # ``is_devrelease``, not ``is_prerelease`` -- the latter is also ``true`` + # for a deliberately-tagged alpha/beta/rc (e.g. ``1.5.0b14``, a real + # release), and gating on it would silently block one. ``github``, ``tag``, + # ``pypi`` and ``conda`` all read this below so that a scheduled Vendor + # Update bumping a dev version never tags or publishes it on the strength + # of ``github-release``'s one reviewer alone, who can miss a stray + # ``.devN`` in an otherwise-unrelated approval; see + # docs/source/contributing/releasing.rst. - name: Get Version id: get_version run: | @@ -236,6 +269,7 @@ jobs: { echo "PCAPKIT_VERSION=$(python -c 'import pcapkit; print(pcapkit.__version__)')" echo "PCAPKIT_PRERELEASE=$(python -c 'from packaging.version import Version; import pcapkit; print(str(Version(pcapkit.__version__).is_prerelease).lower())')" + 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")')" } >> "$GITHUB_OUTPUT" @@ -310,6 +344,7 @@ jobs: outputs: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_PRERELEASE: ${{ steps.get_version.outputs.PCAPKIT_PRERELEASE }} + PCAPKIT_DEV_RELEASE: ${{ steps.get_version.outputs.PCAPKIT_DEV_RELEASE }} PCAPKIT_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} PCAPKIT_CONDA_TAG_EXISTS: ${{ steps.check_conda_tag.outputs.exists }} PCAPKIT_PYPI_COMPLETE: ${{ steps.check_pypi.outputs.PCAPKIT_PYPI_COMPLETE }} @@ -324,12 +359,18 @@ jobs: # No ``environment:``: no outward effect, and a job calling a reusable # workflow may not declare one. Runs exactly when a publisher could (#1052, - # #1748); see the note above ``jobs:``. + # #1748, and, since the dev-release gate below, owner ruling 2026-10-30); + # see the note above ``jobs:``. ``PCAPKIT_DEV_RELEASE`` carries none of the + # other clauses' ``startsWith(github.ref_name, 'v')`` exemption, matching + # ``github``, ``tag``, ``pypi`` and ``conda`` below: a dev version can never + # be published regardless of how this run started, so spending the ~20 + # minutes here on one is the same waste #1052 added this job to avoid. unit-tests: name: Release test gate needs: [ version_check ] if: >- ${{ needs.version_check.result == 'success' && + needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || @@ -360,6 +401,7 @@ jobs: needs: [ unit-tests, version_check ] if: >- ${{ (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && + needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false') }} steps: - name: Checkout code @@ -494,6 +536,7 @@ jobs: if: >- ${{ !cancelled() && needs.version_check.result == 'success' && needs.unit-tests.result == 'success' && + needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && (needs.github.result == 'success' || needs.github.result == 'skipped') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS == 'false') }} @@ -580,6 +623,7 @@ jobs: if: >- ${{ !cancelled() && needs.version_check.result == 'success' && needs.unit-tests.result == 'success' && + needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && (needs.github.result == 'success' || needs.github.result == 'skipped') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE == 'false') }} @@ -738,6 +782,7 @@ jobs: if: >- ${{ !cancelled() && needs.version_check.result == 'success' && needs.unit-tests.result == 'success' && + needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && (needs.github.result == 'success' || needs.github.result == 'skipped') && (needs.tag.result == 'success' || needs.tag.result == 'skipped') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && @@ -994,6 +1039,7 @@ jobs: PCAPKIT_CONDA_COMPLETE: ${{ needs.version_check.outputs.PCAPKIT_CONDA_COMPLETE }} PCAPKIT_RELEASED_AT: ${{ needs.version_check.outputs.PCAPKIT_RELEASED_AT }} PCAPKIT_RELEASED_ELSEWHERE: ${{ needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE }} + PCAPKIT_DEV_RELEASE: ${{ needs.version_check.outputs.PCAPKIT_DEV_RELEASE }} PCAPKIT_PYPI_KTLO_310: ${{ needs.pypi.outputs.ktlo_310 }} PCAPKIT_PYPI_KTLO_PYPY310: ${{ needs.pypi.outputs.ktlo_pypy310 }} run: python3 util/release_status.py diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 04848848ae..b118a07488 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -46,7 +46,10 @@ starts the automated path described below. The same script is also the manual entry point: run ``python util/bump_version.py`` and commit the result to ``main`` for a release that is not a vendor-registry refresh -- a feature or a fix that has -landed and is ready to go out. +landed and is ready to go out. Running it while ``main``'s version is still a +``.devN`` one only advances that counter (``1.6.0.dev2`` to ``1.6.0.dev3``) -- +see the dev-release note below -- so it produces another blocked dev version, +not a release. Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``version`` field to match is **not** a safe shortcut: it blocks the release. @@ -72,6 +75,30 @@ warns, without failing, if that entry's heading still reads "unreleased". Each enforces what the changelog convention already asks for. +Owner ruling, 2026-10-30: while a version is still under active development, +``__version__`` should carry that :pep:`440` ``devN`` suffix itself, as a plain +work-in-progress signal distinct from a deliberately-tagged alpha, beta or +release candidate -- this project has shipped those for real (e.g. +``1.5.0b14``), and they must stay publishable. ``version_check``'s +``PCAPKIT_DEV_RELEASE`` output reads ``Version(__version__).is_devrelease``, +never ``is_prerelease`` (which a real beta sets too), and ``github``, ``tag``, +``pypi`` and ``conda`` all refuse to run while it is ``true`` -- so a scheduled +Vendor Update reaching this workflow (see below) can never tag or publish a dev +version on the strength of ``github-release``'s one reviewer approval alone +(:issue:`887`, below). That approval still exists and still matters, but it is one +person approving a diff for reasons usually unrelated to the version string, so +a stray ``.devN`` is easy to miss there; and a re-run of a job that already +skipped ``github`` once does not ask for a fresh approval at all. This gate +closes that in code rather than leaving it to review. + +The suffix comes off only by hand: :file:`util/bump_version.py` has no option +to drop it -- run on a dev version it only advances the ``devN`` counter +(``1.6.0.dev2`` to ``1.6.0.dev3``), never past it. Finishing a dev version +means editing the ``__version__`` assignment in :file:`pcapkit/__init__.py` +to the real final string by hand, and moving :file:`CITATION.cff`'s matching +``version`` field in the same commit, once that version is actually ready to +ship. + Ways to Start a Release ----------------------- diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 15dfcdacfb..a3556987e5 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -441,7 +441,7 @@ def test_release_status_reports_a_failed_ktlo_leg_without_warning(self) -> None: 'PCAPKIT_PYPI_JOB': 'success', 'PCAPKIT_CONDA_JOB': 'success', 'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_TAG_EXISTS': 'false', 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', - 'PCAPKIT_CONDA_COMPLETE': 'false'} + 'PCAPKIT_CONDA_COMPLETE': 'false', 'PCAPKIT_DEV_RELEASE': 'false'} clean, failed = (subprocess.run(command, env=dict(os.environ, **env), capture_output=True, text=True, check=False, timeout=60) for env in (base, dict(base, PCAPKIT_PYPI_KTLO_310='failure', diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index f598e5548e..27dae5ea84 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -1105,7 +1105,8 @@ def release_context(version_check: 'str' = 'success', release_app_bot_id: 'str' = RELEASE_APP_BOT_ID, released_elsewhere: 'str' = 'false', vendor_update: 'str' = 'success', - vendor_updated: 'object' = 'true') -> 'dict[str, object]': + vendor_updated: 'object' = 'true', + dev_release: 'str' = 'false') -> 'dict[str, object]': """The ``needs``/``github``/status context one scenario row stands for. Every default is a value that, on its own, lets a gate run, so a row @@ -1148,12 +1149,19 @@ def release_context(version_check: 'str' = 'success', ``'false'`` for a Vendor Update that skipped ``vendor-update``, or :data:`NULL` for one it could not read or a ``vendor_update`` that skipped. + Owner ruling, 2026-10-30: ``dev_release`` is ``PCAPKIT_DEV_RELEASE``, + whether ``__version__`` carries a PEP 440 dev-release suffix -- ``'false'``, + the value that lets every gate run, unless a row says otherwise, and + ``null`` when named in ``unknown``, the same treatment as + ``released_elsewhere``. + """ outputs = {} # type: dict[str, object] if outputs_present: outputs = {name: ('true' if name in complete else 'false') for name in GATE_EVIDENCE.values()} outputs['PCAPKIT_RELEASED_ELSEWHERE'] = released_elsewhere + outputs['PCAPKIT_DEV_RELEASE'] = dev_release outputs.update({name: NULL for name in unknown}) outputs['PCAPKIT_VERSION'] = '1.5.0' outputs['PCAPKIT_PRERELEASE'] = 'false' @@ -1226,6 +1234,18 @@ def gate_rows(job: 'str') -> 'list[tuple[str, dict[str, object], bool]]': decides; and ``PCAPKIT_RELEASED_ELSEWHERE`` at ``null``, which runs -- the one row that tells ``!= 'true'`` from ``== 'false'`` for it. + * **Owner ruling, 2026-10-30: ``PCAPKIT_DEV_RELEASE=true`` on both ``main`` + and a ``v*`` push**, with this job's own evidence otherwise favourable so + that the dev-release clause alone decides -- unlike the released-elsewhere + and evidence clauses above, this one carries no ``startsWith(github.ref_name, + 'v')`` exemption, so it has to block a direct ``v*`` tag push too; and + ``PCAPKIT_DEV_RELEASE`` at ``null``, which runs -- the same ``!= 'true'`` + spelling convention as ``PCAPKIT_RELEASED_ELSEWHERE``, but not the same + stakes: this row's job is one of the four publishers, so an unset output + here does not merely cost a wasted run the way an unset evidence output + on the release test gate would -- it lets this publisher through + unguarded, risking a dev version actually being tagged or published for + real. Held false in every row but the two cancellation rows: ``cancelled()``. Holding it *true* elsewhere would make each row pass for any mutation that @@ -1290,6 +1310,15 @@ def gate_rows(job: 'str') -> 'list[tuple[str, dict[str, object], bool]]': ref_name.startswith('v'))) rows.append(('ref_name=main, PCAPKIT_RELEASED_ELSEWHERE=null', release_context(unknown=('PCAPKIT_RELEASED_ELSEWHERE',)), True)) + + # Owner ruling, 2026-10-30: a dev-release version must never publish, + # unconditionally -- checked on both ref shapes, since this clause carries + # no `startsWith(github.ref_name, 'v')` exemption. + for ref_name in ('main', 'v1.5.0'): + rows.append((f'ref_name={ref_name}, PCAPKIT_DEV_RELEASE=true', + release_context(ref_name=ref_name, dev_release='true'), False)) + rows.append(('ref_name=main, PCAPKIT_DEV_RELEASE=null', + release_context(unknown=('PCAPKIT_DEV_RELEASE',)), True)) return rows @@ -2457,10 +2486,130 @@ def test_version_check_exposes_the_new_evidence_outputs(self) -> None: """ block = self.jobs['version_check'] - for output in ('PCAPKIT_CONDA_TAG_EXISTS', 'PCAPKIT_PYPI_COMPLETE', 'PCAPKIT_CONDA_COMPLETE'): + for output in ('PCAPKIT_CONDA_TAG_EXISTS', 'PCAPKIT_PYPI_COMPLETE', 'PCAPKIT_CONDA_COMPLETE', + 'PCAPKIT_DEV_RELEASE'): with self.subTest(output=output): self.assertIn(f'{output}:', block) + def test_dev_release_evidence_reads_is_devrelease_not_is_prerelease(self) -> None: + """Owner ruling, 2026-10-30: ``PCAPKIT_DEV_RELEASE`` has to come from + ``Version(...).is_devrelease``, never ``.is_prerelease``. + + The two differ exactly on a deliberately-tagged alpha/beta/rc, which + this project has shipped for real (e.g. ``1.5.0b14``): ``is_prerelease`` + is ``True`` for it too, so gating on that would silently block a + legitimate release. ``is_devrelease`` is ``True`` only for a PEP 440 + dev release (``X.Y.Z.devN``). + + """ + block = self.jobs['version_check'] + self.assertIn('PCAPKIT_DEV_RELEASE=$(python -c ', block) + self.assertIn('Version(pcapkit.__version__).is_devrelease', block) + # The literal assignment line must not itself read .is_prerelease -- + # PCAPKIT_PRERELEASE (a separate, pre-existing output) legitimately + # does, a few lines away, so this narrows to the one line that matters. + dev_release_line = next( + line for line in block.splitlines() if 'PCAPKIT_DEV_RELEASE=$(python -c' in line) + self.assertNotIn('is_prerelease', dev_release_line) + + def test_all_four_publishers_gate_on_dev_release(self) -> None: + """Owner ruling, 2026-10-30: a scheduled Vendor Update is enough to + reach this workflow and carry a version under active development -- + ``__version__`` carrying a PEP 440 dev-release suffix -- all the way to + ``github-release``'s one reviewer approval (see the module docstring). + That approval exists, but it is one person approving a diff for + reasons usually unrelated to the version string, so a stray ``.devN`` + is easy to miss there, and a re-run of a job that already skipped + ``github`` once does not ask for a fresh approval at all -- so a dev + release must never be tagged or published regardless. Checked as a + top-level ``&&`` conjunct, for the same reason + ``test_version_check_must_strictly_succeed`` is: a disjunct or a + negation could keep the same tokens while nullifying the gate. + + Unlike :data:`GATE_EVIDENCE`, this clause is the same for all four + publishing jobs, ``github`` included -- there is no reason a human + hand-pushing a ``v*`` tag naming a dev-release version should be let + through either, so it carries none of the other clauses' + ``startsWith(github.ref_name, 'v')`` exemption. + + """ + for name in ('github', 'tag', 'pypi', 'conda'): + with self.subTest(job=name): + compact = re.sub(r'\s+', '', declared_if(self.jobs[name])) + self.assertIn( + "&&needs.version_check.outputs.PCAPKIT_DEV_RELEASE!='true'&&", compact, + f'`{name}` does not gate on PCAPKIT_DEV_RELEASE as a top-level ' + f'`&&` conjunct, so a dev-release version (__version__ carrying a ' + f'.devN suffix) could be tagged or published on the strength of a ' + f'single reviewer approval that was not actually about the version', + ) + self.assertEqual( + compact.count('PCAPKIT_DEV_RELEASE'), 1, + f'`{name}` refers to PCAPKIT_DEV_RELEASE more than once; the gate ' + f"is one comparison, != 'true', and a second reference can only " + f'weaken it', + ) + + def test_dev_release_output_wiring_matches_character_for_character(self) -> None: + """A typo'd output name leaves ``PCAPKIT_DEV_RELEASE`` unset forever, + which every job's ``!= 'true'`` comparison then reads as "not a dev + release" -- a gate that looks present in the YAML but never actually + fires. The same silent-open shape + :mod:`tests.project.test_release_tags_the_run_sha_1634`'s + ``TestTheOwnerStep.test_both_outputs_reach_the_jobs`` guards + ``PCAPKIT_RELEASED_ELSEWHERE`` against; ``PCAPKIT_DEV_RELEASE`` comes + from ``version_check``'s ``get_version`` step, not its ``owner`` step, + so it is checked here instead of there. + + :meth:`test_all_four_publishers_gate_on_dev_release` already compares + the compacted (whitespace-stripped) ``if:`` text; this checks the + producer side too -- ``version_check``'s own ``outputs:`` block has to + name the step that actually sets it -- and pins the consumer text + verbatim, uncompacted. + + """ + self.assertIn( + 'PCAPKIT_DEV_RELEASE: ${{ steps.get_version.outputs.PCAPKIT_DEV_RELEASE }}', + self.jobs['version_check'], + "`version_check`'s `outputs:` block does not wire PCAPKIT_DEV_RELEASE " + 'to the `get_version` step that computes it, so every job reading ' + '`needs.version_check.outputs.PCAPKIT_DEV_RELEASE` sees an empty string', + ) + for name in ('github', 'tag', 'pypi', 'conda'): + with self.subTest(job=name): + self.assertIn( + "needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true'", + self.jobs[name], + f'`{name}` does not reference PCAPKIT_DEV_RELEASE exactly as ' + f'`version_check` names it', + ) + # `release_status` reads the same output through its own `env:`, to + # report a dev-release skip as a deliberate no-op rather than #888. + self.assertIn( + 'PCAPKIT_DEV_RELEASE: ${{ needs.version_check.outputs.PCAPKIT_DEV_RELEASE }}', + self.jobs['release_status'], + ) + + def test_dev_release_gate_names_is_devrelease_not_is_prerelease(self) -> None: + """The other way this gate silently fails open: swapping in + ``PCAPKIT_PRERELEASE`` (``is_prerelease``) by mistake. That output is + ``true`` for a deliberately-tagged alpha/beta/rc this project has + shipped for real (e.g. ``1.5.0b14``) as well as for a dev release, so + gating on it would block a legitimate release -- the owner ruling + :meth:`test_dev_release_evidence_reads_is_devrelease_not_is_prerelease` + already pins at the source. This checks the *consumer* side: every job + that gates on the dev-release output names ``PCAPKIT_DEV_RELEASE``, not + ``PCAPKIT_PRERELEASE``, which reads identically to a careless search + and replace, so it is worth asserting directly rather than trusting + the variable name in the ``if:`` alone. + + """ + for name in ('github', 'tag', 'pypi', 'conda'): + with self.subTest(job=name): + condition = declared_if(self.jobs[name]) + self.assertIn('PCAPKIT_DEV_RELEASE', condition) + self.assertNotIn('PCAPKIT_PRERELEASE', condition) + def test_pypi_evidence_reads_the_per_version_endpoint(self) -> None: """Not ``releases[]``: that field was empty on every live request made while building this check, including against a version @@ -2540,6 +2689,13 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): release_context(released_elsewhere='true'), False), ('a v* tag push of a version tagged at another commit', release_context(ref_name='v1.5.0', released_elsewhere='true'), True), + # Owner ruling, 2026-10-30: unconditional, so it blocks a v* push too. + ('a fresh version carrying a dev-release suffix', + release_context(dev_release='true'), False), + ('a v* tag push of a version carrying a dev-release suffix', + release_context(ref_name='v1.5.0', dev_release='true'), False), + ('PCAPKIT_DEV_RELEASE unset', + release_context(unknown=('PCAPKIT_DEV_RELEASE',)), True), ] #: ``(row name, context, whether version_check must run)``. Its ``if:`` @@ -2624,6 +2780,15 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): release_context(ref_name='v1.5.0', released_elsewhere='true'), True), ('whether the tag names another commit unknown', release_context(unknown=('PCAPKIT_RELEASED_ELSEWHERE',)), True), + # Owner ruling, 2026-10-30: a dev version can never be published, by + # any trigger, so the ~20-minute gate is wasted on it too -- the same + # reasoning #1052 added this job's `if:` for in the first place. + ('main is on a dev version, nothing published yet', + release_context(dev_release='true'), False), + ('a v* tag push of a dev version', + release_context(ref_name='v1.6.0.dev2', dev_release='true'), False), + ('whether main is on a dev version unknown', + release_context(unknown=('PCAPKIT_DEV_RELEASE',)), True), ] def _assert_rows(self, job: 'str', @@ -2648,7 +2813,7 @@ def test_the_tables_are_not_empty(self) -> None: runs, which is #888 rather than a fix for it. """ - for job, expected in (('tag', 25), ('pypi', 25), ('conda', 29)): + for job, expected in (('tag', 28), ('pypi', 28), ('conda', 32)): with self.subTest(job=job): rows = gate_rows(job) self.assertEqual( @@ -2904,6 +3069,16 @@ class TestGateMutationsChangeTheTruthTable(WorkflowMixin, unittest.TestCase): ("the released-elsewhere check becomes == 'false', so an unset output skips", "PCAPKIT_RELEASED_ELSEWHERE != 'true'", "PCAPKIT_RELEASED_ELSEWHERE == 'false'", ('evaluator',)), + # Owner ruling, 2026-10-30. The evaluator alone: text_gate_violations + # mirrors only the #888 evidence-and-predecessor assertions this + # module already made before this clause existed, and extending it + # is not required for the clause to be checked -- gate_rows carries + # the rows that catch this, and test_all_four_publishers_gate_on_dev_release + # (TestEvidenceBasedGating) is the text-layer assertion for it, run + # directly against the real file rather than through this mirror. + ('the dev-release conjunct is dropped entirely', + "needs.version_check.outputs.PCAPKIT_DEV_RELEASE != 'true' && ", '', + ('evaluator',)), ] def _mutate(self, condition: 'str', job: 'str', old: 'str', new: 'str') -> 'str': @@ -3330,6 +3505,7 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] 'PCAPKIT_RUN_SHA': RUN_COMMIT, 'PCAPKIT_RELEASED_AT': RUN_COMMIT, 'PCAPKIT_RELEASED_ELSEWHERE': 'false', + 'PCAPKIT_DEV_RELEASE': 'false', } env.update(overrides) return subprocess.run( # type: ignore[return-value] @@ -3537,6 +3713,56 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] '::error title=Stranded partial release (#888)', 1, ), + ( + # Owner ruling, 2026-10-30: the version is a dev version, so the + # PCAPKIT_DEV_RELEASE gate skipped all four publishers on purpose. + # Their evidence stays false -- nothing was ever tagged or + # published -- which must not read as the #888 stranded shape. + 'dev_release_skips_every_publisher', + { + 'PCAPKIT_DEV_RELEASE': 'true', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + }, + '::notice title=Nothing to release::this run\'s version', + 0, + ), + ( + # The critical regression check: the identical skip pattern with + # PCAPKIT_DEV_RELEASE false must still be the loud #888 shape. + 'the_same_skip_pattern_with_dev_release_false_stays_888', + { + 'PCAPKIT_DEV_RELEASE': 'false', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + }, + '::error title=Stranded partial release (#888)', + 1, + ), + ( + # A dev flag with a publisher that ran is not the deliberate + # no-op: only every publisher skipping is. + 'dev_release_with_a_publisher_that_failed', + { + 'PCAPKIT_DEV_RELEASE': 'true', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'failure', 'PCAPKIT_CONDA_JOB': 'skipped', + }, + '::warning title=Release did not complete', + 0, + ), + ( + # Run 41098750345, 1.5.1's partial publish, with the dev flag + # empty: only 'true' opens the quiet branch. + 'the_1_5_1_shape_with_the_dev_flag_empty_stays_888', + { + 'PCAPKIT_DEV_RELEASE': '', 'PCAPKIT_VERSION': '1.5.1', 'PCAPKIT_TAG_EXISTS': 'true', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + }, + '::error title=Stranded partial release (#888)', + 1, + ), ( # #1748's side finding: Vendor Update 38650191283 concluded # `success` with vendor-update skipped, so version_check skipped. diff --git a/tests/project/test_release_status_scripts_1967.py b/tests/project/test_release_status_scripts_1967.py index 1a02db4de9..37d4c4c64d 100644 --- a/tests/project/test_release_status_scripts_1967.py +++ b/tests/project/test_release_status_scripts_1967.py @@ -145,6 +145,16 @@ exit 0 fi + # Owner ruling, 2026-10-30: this run's version carries a PEP 440 dev-release + # suffix, and the PCAPKIT_DEV_RELEASE gate skips every publisher for it on + # purpose, so this is a deliberate no-op, never the #888 shape. + if [ "${PCAPKIT_DEV_RELEASE:-}" = "true" ] && [ "$PCAPKIT_GITHUB_JOB" = "skipped" ] \ + && [ "$PCAPKIT_TAG_JOB" = "skipped" ] && [ "$PCAPKIT_PYPI_JOB" = "skipped" ] \ + && [ "$PCAPKIT_CONDA_JOB" = "skipped" ]; then + echo "::notice title=Nothing to release::this run's version, ${PCAPKIT_VERSION}, carries a PEP 440 dev-release suffix (.devN), so the PCAPKIT_DEV_RELEASE gate skipped every publisher deliberately: this run correctly released nothing." + exit 0 + fi + # From here the evidence outputs are meaningful. A skipped gate (#1052) # falls through on purpose: it skips only when every evidence output # reads 'true' off a v* ref, reported as "Nothing to release" below; if @@ -258,8 +268,8 @@ 'PCAPKIT_GITHUB_JOB': 'success', 'PCAPKIT_TAG_JOB': 'success', 'PCAPKIT_PYPI_JOB': 'success', 'PCAPKIT_CONDA_JOB': 'success', 'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_TAG_EXISTS': 'false', 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', 'PCAPKIT_CONDA_COMPLETE': 'false', - 'PCAPKIT_RELEASED_AT': RUN, 'PCAPKIT_RELEASED_ELSEWHERE': 'false', 'PCAPKIT_PYPI_KTLO_310': '', - 'PCAPKIT_PYPI_KTLO_PYPY310': '', + 'PCAPKIT_RELEASED_AT': RUN, 'PCAPKIT_RELEASED_ELSEWHERE': 'false', 'PCAPKIT_DEV_RELEASE': 'false', + 'PCAPKIT_PYPI_KTLO_310': '', 'PCAPKIT_PYPI_KTLO_PYPY310': '', # The Vendor Update's own event. Only a manual one reads differently, a # branch the shell form lacks; test_build_only_dispatch.py pins it. 'PCAPKIT_WORKFLOW_RUN_EVENT': 'push', @@ -315,6 +325,21 @@ ('elsewhere and a publisher ran', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_CONDA_JOB': 'failure'}), ('elsewhere with no owner output', {**ALL_SKIPPED, 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_RELEASED_ELSEWHERE': '', 'PCAPKIT_RELEASED_AT': ''}), + ('a dev release skips every publisher', {**ALL_SKIPPED, 'PCAPKIT_DEV_RELEASE': 'true'}), + # The critical regression check: the identical skip pattern with + # PCAPKIT_DEV_RELEASE false (not a dev release) must still be the loud + # #888 shape, not swallowed by the new branch above. + ('the same skip pattern with dev release false stays 888', {**ALL_SKIPPED, 'PCAPKIT_DEV_RELEASE': 'false'}), + # A dev flag does not make a publisher that ran quiet: only every one + # skipping is the deliberate no-op. + ('a dev release with a publisher that failed', {**ALL_SKIPPED, 'PCAPKIT_DEV_RELEASE': 'true', + 'PCAPKIT_PYPI_JOB': 'failure'}), + # Run 41098750345, 1.5.1's partial publish: only the dev flag's 'true' + # opens the quiet branch, so an empty or absent one stays #888. + ('the 1.5.1 shape with the dev flag empty', {**ALL_SKIPPED, 'PCAPKIT_VERSION': '1.5.1', + 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_DEV_RELEASE': ''}), + ('the 1.5.1 shape with the dev flag unset', {**ALL_SKIPPED, 'PCAPKIT_VERSION': '1.5.1', + 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_DEV_RELEASE': UNSET}), ('steady state', {**ALL_SKIPPED, **ALL_DONE}), ('a fresh release', {}), ('a self-heal', {'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', 'PCAPKIT_TAG_EXISTS': 'true', @@ -344,6 +369,11 @@ 'PCAPKIT_RELEASE_APP_BOT_ID': UNSET}), ('the version unset elsewhere', {**ELSEWHERE, 'PCAPKIT_VERSION': UNSET}), ('an evidence output unset', {**ALL_SKIPPED, 'PCAPKIT_CONDA_COMPLETE': UNSET}), + # The shell form reads every publisher's evidence before any verdict, so a + # later skipped publisher's unset evidence ends it even after an earlier + # failure; reconciling with a short-circuiting generator would not. + ('an evidence output unset after a failure', {'PCAPKIT_GITHUB_JOB': 'failure', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_CONDA_TAG_EXISTS': UNSET}), ('the run sha unset where it is named', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_RUN_SHA': UNSET}), ) @@ -360,6 +390,7 @@ 'the gate did not pass': (0, r'::warning title=Release blocked by the test gate::'), 'released elsewhere, complete': (0, r'::notice title=Nothing to release::v.* names '), 'released elsewhere, unfinished': (0, r'::warning title=v.* is unfinished \(#1634\)::'), + 'a dev release skipped every publisher': (0, r"::notice title=Nothing to release::this run's version, "), 'already released': (0, r'::notice title=Nothing to release::pypcapkit '), 'released': (0, r'::notice title=Release complete::'), 'a publisher broke': (0, r'::warning title=Release did not complete::'), @@ -501,6 +532,7 @@ def sampled_status_cases(count: 'int' = 150) -> 'list[tuple[str, dict[str, Optio 'PCAPKIT_UNIT_TESTS': (results, [4, 4, 1, 1, 1]), 'PCAPKIT_VERSION_CHECK': (results, [12, 3, 1, 1, 1]), 'PCAPKIT_RELEASED_ELSEWHERE': (flags, [2, 4, 1]), + 'PCAPKIT_DEV_RELEASE': (flags, [2, 4, 1]), 'PCAPKIT_PYPI_KTLO_310': (['', 'success', 'failure'], [4, 1, 1]), 'PCAPKIT_PYPI_KTLO_PYPY310': (['', 'success', 'failure'], [4, 1, 1]), 'PCAPKIT_VERSION': (['1.5.0', '1.5.0b12'], [1, 1]), diff --git a/util/release_status.py b/util/release_status.py index 4fdbedc318..4c492cbc74 100644 --- a/util/release_status.py +++ b/util/release_status.py @@ -20,7 +20,10 @@ 4. The version is tagged at another commit and every publisher skipped (#1748): a notice if that release is complete, else a warning naming what it lacks, and, for Anaconda files, Conda Update's ``publish`` dispatch (#2140). -5. Each publisher reconciled against its own evidence -- succeeded, or skipped +5. The version carries a PEP 440 dev-release suffix and every publisher + skipped for it: a quiet notice, since the ``PCAPKIT_DEV_RELEASE`` gate did + that on purpose (owner ruling, 2026-10-30) -- never the #888 shape. +6. Each publisher reconciled against its own evidence -- succeeded, or skipped with its evidence saying done: a notice if all reconciled, a warning if one failed or was cancelled, and otherwise the ``::error``. @@ -155,6 +158,25 @@ def report_elsewhere() -> 'None': f"{var('PCAPKIT_RELEASED_AT')}, or release the next version.{anaconda}") +def dev_release_skipped() -> 'bool': + """Whether this run's version is a dev release, with every publisher skipped for it. + + ``PCAPKIT_DEV_RELEASE`` may legitimately be absent -- a workflow run old + enough to predate this gate -- so it is read with a default rather than + through :func:`var`, unlike the evidence outputs in :data:`TARGETS`. + + """ + return (os.environ.get('PCAPKIT_DEV_RELEASE', '') == 'true' + and all(var(job) == 'skipped' for job, _ in TARGETS)) + + +def report_dev_release() -> 'None': + """Case 5: every publisher skipped on purpose, because this run's version is a dev version.""" + say('notice', 'Nothing to release', + f"this run's version, {var('PCAPKIT_VERSION')}, carries a PEP 440 dev-release suffix (.devN), so the " + 'PCAPKIT_DEV_RELEASE gate skipped every publisher deliberately: this run correctly released nothing.') + + def jobs() -> 'str': """``github=... tag=... pypi=... conda=...``, each publisher's result.""" return (f"github={var('PCAPKIT_GITHUB_JOB')} tag={var('PCAPKIT_TAG_JOB')} pypi={var('PCAPKIT_PYPI_JOB')} " @@ -190,10 +212,18 @@ def report() -> 'int': report_elsewhere() return 0 + if dev_release_skipped(): + report_dev_release() + return 0 + # Reconcile each job against its OWN evidence, not against each other: a # self-heal mixes jobs that skipped because their artefact existed with jobs # that ran, and demanding uniformity would read that mix as #888. - all_reconciled = all([reconciled(job, evidence) for job, evidence in TARGETS]) + # NOTE: a list, not a generator, on purpose: every publisher's evidence is + # read before any verdict, as the shell form's ``set -u`` reads each one, + # so an unset evidence variable ends the script even after a failure. A + # generator would short-circuit at the first unreconciled publisher. + all_reconciled = all([reconciled(job, evidence) for job, evidence in TARGETS]) # pylint: disable=use-a-generator results = [var(job) for job, _ in TARGETS] all_skipped = all(result == 'skipped' for result in results) any_broken = any(result in ('failure', 'cancelled') for result in results)