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.