From f9c96dda345d1ac5e2c36d4c9698f3d10710a8ba Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 25 Oct 2026 09:59:07 -0400 Subject: [PATCH] docs(util): document that CITATION.cff dates a release by its version bump (#2163) Problem: util/bump_version.py's docstring and CITATION.cff's header framed date-released as the publish date and the bump-time date as a gap, to be closed by eventually moving both fields to create-release.yml. Fix: per the owner's ruling, date-released is the date of the commit that bumps the version, not the date the release is distributed. State that in the module docstring, CITATION.cff's header and releasing.rst, and drop the create-release.yml aspiration and the publish-date framing. Test: test_a_bump_dates_the_release_by_the_bump_itself runs main() against a scratch tree and pins that a bump writes the run's UTC date into date-released alongside the new version. --- CITATION.cff | 7 +-- docs/source/contributing/releasing.rst | 5 ++- tests/project/test_bump_version.py | 62 +++++++++++++++++++++----- util/bump_version.py | 52 +++++++++------------ 4 files changed, 80 insertions(+), 46 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 1a75a6bc31..ad407fb2ee 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,9 +7,10 @@ # into a paper or a bill of materials instead of depending on a reader noticing # ``LICENSE``. It grants nothing and restricts nothing; the licence is unchanged. # -# ``version`` and ``date-released`` describe the newest *published* release, so they -# move with ``pcapkit.__version__`` in ``pcapkit/__init__.py`` and the matching entry -# under ``docs/source/changelog/`` when a release goes out. +# ``version`` and ``date-released`` move with ``pcapkit.__version__`` in +# ``pcapkit/__init__.py`` and the matching entry under ``docs/source/changelog/``, +# in the commit that bumps the version. ``date-released`` is that commit's date, +# not the date the release is later published (#2163). # # No ``doi`` and no ``orcid``: neither exists for this project or its author, # and both fields are checked formats rather than free text, so an invented value diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index e3842a5fce..90f5203cea 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -25,7 +25,10 @@ counter, or a fresh ``.post1`` for a final release -- and then: * rewrites the ``__version__`` assignment in ``pcapkit/__init__.py``; * moves :file:`CITATION.cff`'s ``version`` and ``date-released`` fields to - match, line-oriented so the file's own quoting and comments survive; + match, line-oriented so the file's own quoting and comments survive. + ``date-released`` becomes the bump's own date in UTC, not the date the + release is later published, which is its intended meaning + (:issue:`2163`); * resets :file:`conda/build` to ``0``. Its one scheduled caller is the ``Bump Version`` step of diff --git a/tests/project/test_bump_version.py b/tests/project/test_bump_version.py index 0c7325e58e..86c5ecdabf 100644 --- a/tests/project/test_bump_version.py +++ b/tests/project/test_bump_version.py @@ -27,15 +27,18 @@ :mod:`pcapkit`, because the question is what the file in the repository says, and an installed copy elsewhere on ``sys.path`` would answer a different one. -What is deliberately not tested -------------------------------- - -:func:`~bump_version.main` is never called. It ends by writing ``conda/build`` -relative to the working directory and by resolving -:file:`pcapkit/__init__.py` through ``pcapkit.__path__``, so calling it in-process -would rewrite the checkout running the tests. The pieces it wires together are -each tested directly instead, and the ordering property that matters -- that the -one fatal outcome is reached before any file is written -- is structural: +Calling ``main`` +---------------- + +:func:`~bump_version.main` is called once, with the version reader and +:func:`~bump_version.init_path` patched and the working directory moved into the +scratch tree, because it ends by writing ``conda/build`` relative to the working +directory and by resolving :file:`pcapkit/__init__.py` through +``pcapkit.__path__``; unpatched, it would rewrite the checkout running the tests. +That one call pins what a bump writes into ``date-released`` (#2163). Otherwise +the pieces it wires together are each tested directly, and the ordering +property that matters -- that the one fatal outcome is reached before any file is +written -- is structural: :func:`~bump_version.plan_citation` takes text and returns text, so it has nothing to write with. @@ -190,9 +193,8 @@ def test_the_release_date_moves_with_the_version(self) -> None: self.assertNotIn("date-released: '2026-09-20'", rewritten) def test_the_release_date_defaults_to_today_in_utc(self) -> None: - # Not ``date.today()``. Seven of the thirty most recent bumps were made - # late evening in US-Eastern, where the local date is a day behind the - # publish the field is describing. + # Not ``date.today()``. A bump made late evening in US-Eastern has a local + # date a day behind the UTC date of the commit the field is describing. opened = datetime.datetime.now(datetime.timezone.utc).date() rewritten = bump_version.plan_citation(FIXTURE, '1.5.0b5') closed = datetime.datetime.now(datetime.timezone.utc).date() @@ -212,6 +214,42 @@ def test_the_release_date_defaults_to_today_in_utc(self) -> None: self.assertNotIn(f"date-released: '{local.isoformat()}'", rewritten, 'the local date was written where UTC was required') + def test_a_bump_dates_the_release_by_the_bump_itself(self) -> None: + # #2163: ``date-released`` is the date of the commit that bumps the + # version, not the date the release is distributed. So the bump writes the + # run's own UTC date into the field, in the same write that moves + # ``version``, rather than leaving it for a release step to fill in. + citation = self.write_citation() + package = self.tmp_path / 'pcapkit' + package.mkdir() + init = package / '__init__.py' + init.write_text("__version__ = '1.5.0b4'\n", encoding='utf-8') + (self.tmp_path / 'conda').mkdir() + + # ``main`` writes ``conda/build`` relative to the working directory and + # finds ``__init__.py`` through ``pcapkit``, so both are pointed at the + # scratch tree to keep the checkout running the tests untouched. + cwd = os.getcwd() + self.addCleanup(os.chdir, cwd) + os.chdir(self.tmp_path) + + with mock.patch.object(bump_version, 'current_version', return_value='1.5.0b4'), \ + mock.patch.object(bump_version, 'init_path', return_value=str(init)): + opened = datetime.datetime.now(datetime.timezone.utc).date() + self.assertEqual(bump_version.main(['--citation', str(citation)]), 0) + closed = datetime.datetime.now(datetime.timezone.utc).date() + + written = citation.read_text(encoding='utf-8') + self.assertIn('\nversion: 1.5.0b5\n', written) + # Both bounds, so a run straddling UTC midnight does not flake. + wanted = {f"\ndate-released: '{day.isoformat()}'\n" for day in (opened, closed)} + self.assertTrue( + any(candidate in written for candidate in wanted), + f'expected the bump to date the release {sorted(wanted)}, got ' + f'{[ln for ln in written.splitlines() if ln.startswith("date-released:")]!r}', + ) + self.assertIn("__version__ = '1.5.0b5'", init.read_text(encoding='utf-8')) + def test_a_file_without_a_release_date_does_not_gain_one(self) -> None: # The field is optional in Citation File Format 1.2.0. Inventing one would # assert a release date the repository never recorded. diff --git a/util/bump_version.py b/util/bump_version.py index 08e2e40ea2..fab7842ddb 100644 --- a/util/bump_version.py +++ b/util/bump_version.py @@ -31,33 +31,23 @@ out of this script it would state the *previous* release forever, which is worse than having no citation file at all, because a stale one is still consumed. -Both fields move together, and the reason is that they have the same standing. The -file's own header says ``version`` and ``date-released`` both "describe the newest -*published* release", so at the moment of a bump both are equally anticipatory -- -there is no principled line that lets one move and pins the other. Moving only -``version`` would leave the pair asserting something plainly false and leave it -there indefinitely: "1.5.0b5, released on the day 1.5.0b4 was". - -Measured over the thirty most recent releases, the bump commit's UTC calendar date -equals the PyPI upload's UTC calendar date **thirty times out of thirty**, median -gap three minutes. The weekly cron fires at 10:05 UTC, twelve hours clear of a -date boundary. Hence UTC via :func:`datetime.datetime.now` with an -explicit timezone, not :meth:`datetime.date.today`: seven of those thirty bumps -were made late evening in US-Eastern, where a naive local date would have been a -day behind the publish it was describing. - -The residual risk is stated rather than hidden. Roughly one bump in twelve -historically never reached PyPI at all -- eight consecutive weekly bumps between -2024-04-13 and 2024-06-15, and ``1.5.0b1`` -- and for those this script writes an -optimistic date for a release that never happened. That is not an argument for -leaving the field stale, since a stale date is wrong in *every* one of the other -eleven cases; it is an argument for eventually anchoring both fields to -:file:`.github/workflows/create-release.yml`, which runs only when a publish is -actually going out. That would also give the right date to a release approved on -a later day than its bump, which this script dates by the bump: ``1.5.0b9``'s -Create Release run was approved three days after it was created (#1634). Until -then this is the better of the two available answers, and the ``version`` half -is guarded by a test that fails when the file drifts from +``date-released`` is the date of the commit that bumps the version, not the date +the release is distributed (#2163). The release goes out only once the +``github-release`` environment is approved (#887), so the two can differ: a +release approved days after its bump keeps the bump's date, as ``1.5.0b9``'s +was (#1634), and a bump that never reaches PyPI (``1.5.0b1``'s did not) keeps +its date too. That is the intended meaning rather than a gap to close, which is +why this script writes the field and no release step does. Both fields move in +the same commit so that they describe the same bump; moving only ``version`` +would leave the pair asserting something plainly false: "1.5.0b5, released on +the day 1.5.0b4 was". + +The date is taken in UTC via :func:`datetime.datetime.now` with an explicit +timezone, not :meth:`datetime.date.today`, so a bump run by hand late in the +evening in US-Eastern records the commit's UTC calendar date rather than a local +one a day behind it. + +The ``version`` half is guarded by a test that fails when the file drifts from ``pcapkit.__version__`` -- which also covers the version changes made by hand, which never run this script at all. Those are about a quarter of the commits that have moved ``__version__`` on ``main`` over the project's life, and a larger @@ -156,8 +146,9 @@ #: format version, which must *not* move -- does not begin with this prefix. VERSION_KEY = 'version:' -#: Top-level ``CITATION.cff`` key naming the release date. Optional in the -#: format, so its absence is reported rather than filled in. +#: Top-level ``CITATION.cff`` key naming the release date, which is the date of +#: the version bump rather than of the publish. Optional in the format, so its +#: absence is reported rather than filled in. RELEASED_KEY = 'date-released:' @@ -314,7 +305,8 @@ def plan_citation(text: str, version: str, released: Optional[str] = None) -> st Args: text: Current contents of the citation file. version: Version string to record. - released: Release date as ``YYYY-MM-DD``, defaulting to today in UTC. + released: Release date as ``YYYY-MM-DD``, defaulting to today in UTC, + which is the date of the bump this call is part of. Only written if the file already carries a ``date-released`` field; the field is optional in the format and is not invented.