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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 55 additions & 3 deletions scripts/check_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand Down
69 changes: 67 additions & 2 deletions tests/unit/test_check_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import importlib.util
from datetime import date
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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)
Loading