tools/stamp_changelog.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Put a wall-clock time on every changelog heading.
A date alone is useless on a day like 2026-08-19, when ten releases shipped
between breakfast and lunch: twenty-nine entries all claiming the same day
tell you nothing about their order or their spacing. The time does.
The truth about when a release happened is its commit time, so that is where
this reads from, converted to the zone the work actually happened in rather
than left in UTC — a release stamped 01:52Z shipped the *previous* evening in
California, and a changelog that says otherwise is lying about the day.
Idempotent: a heading that already carries a time is left exactly as it is.
Run it after a release commit; unstamped headings get filled in.
"""
from __future__ import annotations
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
CHANGELOG = ROOT / "CHANGELOG.md"
#: Where the work happens. Not UTC: a changelog is read by a person who
#: remembers what they were doing at four o'clock, not at 23:00Z.
ZONE = ZoneInfo("America/Los_Angeles")
# "## 0.5.0 — 2026-08-19 — **now CLIque**" — version, date, then anything else
# the heading wanted to say, which is preserved untouched.
HEAD = re.compile(r"^## (?P<ver>\S+) [—-] (?P<date>\d{4}-\d{2}-\d{2})(?P<rest>.*)$")
STAMPED = re.compile(r"^ \d{2}:\d{2} ")
def release_times() -> dict[str, datetime]:
"""Version -> when it was committed. Versions live in the subject line."""
out = subprocess.run(
["git", "log", "--format=%cI\x1f%s"],
cwd=ROOT, capture_output=True, text=True, check=True,
).stdout
found: dict[str, datetime] = {}
for line in out.splitlines():
iso, _, subject = line.partition("\x1f")
# Releases name their version in parentheses at the end; the very
# first one predates that habit and just says "v0.1.0".
# Later ones lead with it: "0.95.0: keep scrollback, ...".
match = (re.search(r"\((\d+\.\d+\.\d+)\)\s*$", subject)
or re.match(r"(\d+\.\d+\.\d+):", subject)
or re.search(r"\bv(\d+\.\d+\.\d+)\b", subject))
if not match:
continue
# git log is newest-first, so the first sighting of a version is the
# commit that shipped it and any later one is a rewrite of history.
found.setdefault(match.group(1), datetime.fromisoformat(iso).astimezone(ZONE))
return found
def main() -> int:
text = CHANGELOG.read_text(encoding="utf-8")
times = release_times()
now = datetime.now(ZONE)
lines = text.splitlines()
changed = 0
for i, line in enumerate(lines):
match = HEAD.match(line)
if not match or STAMPED.match(match.group("rest")):
continue
when = times.get(match.group("ver"))
if when is None:
# Not committed yet — this is the entry being written right now,
# and "now" is within a minute of the commit that follows.
when = now
lines[i] = (f"## {match.group('ver')} - {when:%Y-%m-%d %H:%M %Z}"
f"{match.group('rest')}")
changed += 1
if changed:
CHANGELOG.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"stamped {changed} heading(s)")
return 0
if __name__ == "__main__":
sys.exit(main())