Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/source/contributing/releasing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 50 additions & 12 deletions tests/project/test_bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down
52 changes: 22 additions & 30 deletions util/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:'


Expand Down Expand Up @@ -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.

Expand Down
Loading