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)