From 2c965f65f6b0eb3c0521ba7ecf071ae8fdd7de1e Mon Sep 17 00:00:00 2001 From: melbinjp Date: Tue, 18 Aug 2026 13:44:07 +0530 Subject: [PATCH] release check: the changelog date has to be true, not just present check_release.py required a release section to carry a date and never asked whether the date was right. 0.3.1 shipped carrying '## [0.3.1] - 2026-08-02' while PyPI recorded the upload on 2026-08-17. Every existing check passed: the version matched, the tag matched, the section had a date and was not empty. The entry was drafted when the work was done and never touched again when it went out fifteen days later. A changelog is read for the one thing a git log does not answer at a glance - when a version reached users - and this one was wrong by two weeks. Only applied when a tag is being released. On the working tree the date is legitimately the day the entry was drafted, and failing a pull request for that would be noise. One day of tolerance either way, because the release runs on a UTC runner and the entry is written in the author's own timezone. Also corrected 0.3.1's date to the PyPI upload date, and pinned 'today' in the two existing tests that pass a tag - they call check() with the real clock, so without that they would have started failing the day after the changelog was last written. --- CHANGELOG.md | 2 +- scripts/check_release.py | 58 +++++++++++++++++++++++++-- tests/unit/test_check_release.py | 69 +++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cca3e9d..0a554f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] -## [0.3.1] - 2026-08-02 +## [0.3.1] - 2026-08-17 ### Added - Every tool now declares what it does to the machine: `title`, and diff --git a/scripts/check_release.py b/scripts/check_release.py index 3f72441..ea30003 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -20,6 +20,9 @@ * Below 1.0.0, a release containing a breaking change bumps MINOR; everything else bumps PATCH. From 1.0.0 onward, strict SemVer: breaking bumps MAJOR. * A release has a changelog section with a date, and it is not empty. + * When a tag is being released, that date is the day it actually goes out. A date + that exists is not the same as a date that is true, and 0.3.1 shipped fifteen + days after the one it carried. """ from __future__ import annotations @@ -28,6 +31,7 @@ import re import subprocess import sys +from datetime import date from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -115,7 +119,47 @@ def released_tags() -> list[tuple[int, int, int]]: return sorted(v for v in versions if v is not None) -def check(tag: str | None) -> list[str]: +CHANGELOG_DATE_TOLERANCE_DAYS = 1 + + +def stale_date_problem( + version: str, + entry_date: date, + today: date, + tolerance_days: int = CHANGELOG_DATE_TOLERANCE_DAYS, +) -> str | None: + """Report a changelog date that is not the day the release actually happens. + + `check` already required the date to EXIST. It never asked whether it was TRUE, and + that gap shipped: rigout 0.3.1 carried `## [0.3.1] - 2026-08-02` while PyPI recorded + the upload on 2026-08-17. The entry was written when the work was done and never + touched again when it went out fifteen days later. Nothing failed, because nothing + looked. A changelog is read for the one thing a git log does not answer at a glance - + when a version reached users - and it was wrong by two weeks. + + Only applied when a tag is being released. On the working tree the date is legitimately + the day the entry was drafted, and failing a pull request for that would be noise. + + One day of tolerance, because the release runs on a UTC runner and the entry is written + in whoever's local timezone, so an honest entry can name the adjacent day. + """ + drift = (today - entry_date).days + if abs(drift) <= tolerance_days: + return None + if drift < 0: + return ( + f"the `## [{version}]` heading is dated {entry_date.isoformat()}, which is " + f"{-drift} days in the future; it should be the day the release goes out ({today.isoformat()})" + ) + return ( + f"the `## [{version}]` heading is dated {entry_date.isoformat()} but this release is " + f"going out on {today.isoformat()}, {drift} days later. The date says when a version " + f"reached users, so an entry drafted early and never updated makes the changelog wrong " + f"about the one thing it is read for." + ) + + +def check(tag: str | None, today: date | None = None) -> list[str]: problems: list[str] = [] version_text = read_project_version(REPO_ROOT / "pyproject.toml") @@ -140,13 +184,21 @@ def check(tag: str | None) -> list[str]: return problems entry = next(item for item in sections if item[0] == version_text) - _, date, start = entry + # Named entry_date rather than date: the module imports `date` from datetime, and the + # obvious local name shadows it exactly where the comparison below needs the type. + _, entry_date, start = entry body = section_body(changelog, start) - if date is None: + if entry_date is None: problems.append( f"the `## [{version_text}]` heading has no date; it should read `## [{version_text}] - YYYY-MM-DD`" ) + elif tag is not None: + stale = stale_date_problem( + version_text, date.fromisoformat(entry_date), today if today is not None else date.today() + ) + if stale is not None: + problems.append(stale) if not body.strip(): problems.append(f"the `## [{version_text}]` section is empty") diff --git a/tests/unit/test_check_release.py b/tests/unit/test_check_release.py index 460cda5..7ad62d0 100644 --- a/tests/unit/test_check_release.py +++ b/tests/unit/test_check_release.py @@ -6,6 +6,7 @@ """ import importlib.util +from datetime import date from pathlib import Path import pytest @@ -125,12 +126,76 @@ def test_the_working_tree_passes(self): assert check_release.check(tag=None) == [] def test_the_matching_tag_passes(self): + """`today` is pinned to the entry's own date on purpose. + + Passing a tag now also checks that the changelog date is the day of release, so + calling the real clock here would make this test start failing the day after the + changelog was last written. Date drift has its own tests below; this one is about + every other rule holding for the release actually being prepared. + """ version = check_release.read_project_version(check_release.REPO_ROOT / "pyproject.toml") - assert check_release.check(tag=f"v{version}") == [] + assert check_release.check(tag=f"v{version}", today=_entry_date_for(version)) == [] def test_a_tag_naming_another_version_is_refused(self): - problems = check_release.check(tag="v9.9.9") + version = check_release.read_project_version(check_release.REPO_ROOT / "pyproject.toml") + problems = check_release.check(tag="v9.9.9", today=_entry_date_for(version)) assert problems assert "does not match the packaged version" in problems[0] + + +def _entry_date_for(version: str) -> date: + """The date this repository's changelog gives for one version.""" + changelog = (check_release.REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + for entry_version, entry_date, _ in check_release.changelog_sections(changelog): + if entry_version == version and entry_date is not None: + return date.fromisoformat(entry_date) + raise AssertionError(f"CHANGELOG.md has no dated section for {version}") + + +@pytest.mark.unit +class TestChangelogDateIsTrue: + """A date that exists is not the same as a date that is right. + + rigout 0.3.1 shipped carrying `## [0.3.1] - 2026-08-02` while PyPI recorded the upload + on 2026-08-17. Every check in this file passed: the version matched, the tag matched, + the section had a date and was not empty. The entry was drafted when the work was done + and never touched again when it went out fifteen days later, so the changelog was wrong + by two weeks about the one thing a changelog is read for. + """ + + def test_the_day_of_release_passes(self): + assert check_release.stale_date_problem("0.3.1", date(2026, 8, 17), date(2026, 8, 17)) is None + + @pytest.mark.parametrize( + ("entry", "today"), + [ + (date(2026, 8, 17), date(2026, 8, 18)), # UTC runner a day ahead of the author + (date(2026, 8, 18), date(2026, 8, 17)), # author a day ahead of the runner + ], + ) + def test_one_day_either_way_is_timezone_slop(self, entry, today): + assert check_release.stale_date_problem("0.3.1", entry, today) is None + + def test_the_drift_that_actually_shipped_is_refused(self): + problem = check_release.stale_date_problem("0.3.1", date(2026, 8, 2), date(2026, 8, 17)) + + assert problem is not None + assert "15 days later" in problem + + def test_a_date_in_the_future_is_refused(self): + problem = check_release.stale_date_problem("0.4.0", date(2026, 9, 1), date(2026, 8, 18)) + + assert problem is not None + assert "in the future" in problem + + def test_the_working_tree_is_never_judged_on_dates(self): + """Without a tag the date is legitimately the day the entry was drafted.""" + assert check_release.check(tag=None, today=date(2030, 1, 1)) == [] + + def test_releasing_a_stale_entry_is_refused(self): + version = check_release.read_project_version(check_release.REPO_ROOT / "pyproject.toml") + problems = check_release.check(tag=f"v{version}", today=date(2030, 1, 1)) + + assert any("reached users" in problem for problem in problems)