#!/usr/bin/env python3 """check-changelog-fragment.py: a pull request that changes WAI says what it changed, in an entry of its own. check-changelog-fragment.py A pull request whose diff (`git diff base...head`) changes anything under `wai/` must add or edit an entry file directly in `wai/changes/` (one entry a file; see wai/changes/README.md), in the changelog's form (scripts/changelog-assemble.py). A file in a subdirectory of `wai/changes/` is refused: the assembler and `wai breaking` read only the directory itself, so such an entry would never reach the changelog. A change with nothing for the changelog to say (a lockfile bump, a fix to a test) says so with the trailer line `Changelog: none`, exactly, in the trailer block of one of its commit messages (the message's last paragraph, after a blank line, every line of it a `Key: value` trailer), and passes. A release's change, which renders the entry files into `wai/CHANGELOG.md` and removes them (`changelog-assemble.py --write`), passes without one. The transition. A pull request opened before this check landed passes as it is: it may edit the head of `wai/CHANGELOG.md` instead, or carry no entry at all. The time it was opened is the pull request's `created_at` in the event payload ($GITHUB_EVENT_PATH). The time the check landed is when it reached the base branch: the committer time of the first commit on the first-parent history of (the pull request's base, the tip of main) whose tree holds this file. When the check comes in by a merge commit, that is the merge's own time, not the time of the branch commit that added the file. Until the check is on the base branch at all, every pull request counts as opened before it landed. One opened since it landed that adds an entry to the head of `## Unreleased` in `wai/CHANGELOG.md` fails, and is told to add the entry as a file of `wai/changes/` instead. With no event payload (a local run), the change is taken as opened now. Exit 0 when the change carries its entry, says it needs none, is a release's, was opened before the check landed, or touches nothing under wai/; 1 otherwise, saying what to do. The check never passes unchecked: a clone too shallow to diff is deepened first, and when the two commits still cannot be compared, or git fails, or the times above cannot be read (a shallow clone whose first-parent history stops before the answer is known), it fails and says why (the workflow checks out the whole history, so this is a fault). Standard library only; runs on Python 3.9. """ from __future__ import annotations import collections import datetime import importlib.util import json import os import posixpath import re import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) WAI = "wai/" CHANGELOG = "wai/CHANGELOG.md" FRAGMENTS = "wai/changes" README = "wai/changes/README.md" SELF = "scripts/" + os.path.basename(__file__) UNRELEASED = "## Unreleased" TRAILER = "Changelog: none" TRAILER_LINE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*:\s") ISO = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})$") class Fault(Exception): """Something the check needs and cannot read: it fails, saying what.""" def git(*args, cwd=None): return subprocess.run(["git", *args], capture_output=True, text=True, cwd=cwd) def must(*args, cwd=None): r = git(*args, cwd=cwd) if r.returncode != 0: raise Fault(f"`git {' '.join(args)}` failed: {r.stderr.strip() or 'exit ' + str(r.returncode)}") return r.stdout def assembler(): spec = importlib.util.spec_from_file_location("changelog_assemble", os.path.join(HERE, "changelog-assemble.py")) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def comparable(base, head): """The merge base of base and head, fetching history once if the clone lacks it.""" mb = git("merge-base", base, head) if mb.returncode == 0: return mb.stdout.strip() if git("rev-parse", "--is-shallow-repository").stdout.strip() == "true": git("fetch", "-q", "--no-tags", "--unshallow", "origin") for sha in (base, head): if git("cat-file", "-e", f"{sha}^{{commit}}").returncode != 0: git("fetch", "-q", "--no-tags", "origin", sha) mb = git("merge-base", base, head) return mb.stdout.strip() if mb.returncode == 0 else None def trailers(message): """The lines of a commit message's trailer block: its last paragraph, when that is not the subject and every line of it is a `Key: value` trailer (or a continuation line of one, which starts with white space); otherwise none.""" paras = [p for p in re.split(r"\n[ \t]*\n", message.strip("\n")) if p.strip()] if len(paras) < 2: return [] out = [] for line in paras[-1].split("\n"): if line[:1] in (" ", "\t") and out: continue if not TRAILER_LINE.match(line): return [] out.append(line.rstrip()) return out def says_none(mb, head): log = must("log", "--format=%B%x00", f"{mb}..{head}") return any(TRAILER in trailers(m) for m in log.split("\x00")) def epoch(text, what): m = ISO.match(text or "") if not m: raise Fault(f"{what} `{text}` is not an ISO 8601 time with its zone") y, mo, d, h, mi, s, z = m.groups() if z == "Z": tz = datetime.timezone.utc else: z = z.replace(":", "") sign = 1 if z[0] == "+" else -1 tz = datetime.timezone(sign * datetime.timedelta(hours=int(z[1:3]), minutes=int(z[3:5]))) return int(datetime.datetime(int(y), int(mo), int(d), int(h), int(mi), int(s), tzinfo=tz).timestamp()) def when(t): return datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") def landed(base): """(commit, its committer time) of the first commit on the first-parent history of base whose tree holds this file: when the check reached the base branch. None when no commit of that history holds it: the check is not on the base branch yet. A shallow clone's history stops at a commit whose parents it lacks, so when that commit already holds the file, or the file is found nowhere before it, the answer is unknown and the check fails.""" out = must("log", "--first-parent", "--reverse", "--format=%H %ct", base) rows = [l.split() for l in out.split("\n") if l.strip()] if not rows or any(len(r) != 2 for r in rows): raise Fault(f"`git log --first-parent {base}` listed no commits, so the time this check landed is unknown") query = "".join(f"{sha}:{SELF}\n" for sha, _ in rows) r = subprocess.run(["git", "cat-file", "--batch-check"], input=query, capture_output=True, text=True) held = r.stdout.split("\n")[:len(rows)] if r.returncode != 0 or len(held) != len(rows) or any(not l.strip() for l in held): raise Fault(f"`git cat-file --batch-check` could not say which commits of {base}'s first-parent history hold {SELF}: " f"{r.stderr.strip() or 'exit ' + str(r.returncode)}") first = next((i for i, l in enumerate(held) if not l.endswith(" missing")), None) shallow = must("rev-parse", "--git-path", "shallow").strip() if os.path.isfile(shallow): with open(shallow, encoding="utf-8") as fh: stops = set(fh.read().split()) oldest = rows[0][0] if oldest in stops and first in (None, 0): found = (f"the first commit of it that holds {SELF}, {oldest[:12]}, is where this shallow clone's history stops" if first == 0 else f"this shallow clone's history stops at {oldest[:12]} before any commit of it holds {SELF}") raise Fault(f"on the first-parent history of {base[:12]}, {found}, so the time this check landed is unknown; " f"check out the whole history (fetch-depth: 0)") if first is None: return None sha, t = rows[first] return sha, int(t) def opened(): """The pull request's creation time, from the event payload, or None with no payload.""" path = os.environ.get("GITHUB_EVENT_PATH") if not path: return None try: with open(path, encoding="utf-8") as fh: event = json.load(fh) created = event["pull_request"]["created_at"] except (OSError, ValueError, KeyError, TypeError) as e: raise Fault(f"the pull request's creation time cannot be read from the event payload {path} ({type(e).__name__}: {e})") return epoch(created, "the pull request's created_at") def unreleased_titles(text): """The `### ` titles of the changelog's Unreleased section.""" lines = text.split("\n") if UNRELEASED not in lines: return [] at = lines.index(UNRELEASED) end = next((i for i in range(at + 1, len(lines)) if lines[i].startswith("## ")), len(lines)) return [l for l in lines[at + 1:end] if l.startswith("### ")] def show(rev, path): r = git("show", f"{rev}:{path}") return r.stdout if r.returncode == 0 else "" def check(base, head): mb = comparable(base, head) if mb is None: raise Fault(f"{base} and {head} cannot be compared in this clone, even after fetching its history; the entry was not checked") d = must("diff", "--name-status", "--no-renames", f"{mb}..{head}") changed = [l.split("\t", 1) for l in d.splitlines() if "\t" in l] wai = [p for _, p in changed if p.startswith(WAI)] if not wai: print("changelog: this change touches nothing under wai/, so it needs no entry") return 0 bad, written, removed = [], [], [] for s, p in changed: if not p.startswith(FRAGMENTS + "/") or p == README: continue if s == "D": if posixpath.dirname(p) == FRAGMENTS and p.endswith(".md"): removed.append(p) elif posixpath.dirname(p) != FRAGMENTS: bad.append(f"{p}: an entry file goes directly in {FRAGMENTS}/, not in a subdirectory: " f"the assembler and `wai breaking` read only {FRAGMENTS}/ itself, so this entry would never reach the changelog") elif not p.endswith(".md"): bad.append(f"{p}: not an entry file: an entry is {FRAGMENTS}/-.md") else: written.append(p) if written: asm = assembler() for p in written: bad += [f"{FRAGMENTS}/{b}" for b in asm.check_fragment(posixpath.basename(p), must("show", f"{head}:{p}"))] # Entries added to the head of Unreleased by hand, rather than rendered there from the # files this change removes (a release). by_hand, release = [], False if any(p == CHANGELOG for _, p in changed): after = show(head, CHANGELOG) new = collections.Counter(unreleased_titles(after)) - collections.Counter(unreleased_titles(show(mb, CHANGELOG))) titles = [show(mb, p).split("\n", 1)[0] for p in removed] by_hand = sorted((new - collections.Counter(titles)).elements()) release = bool(titles) and all(t in after.split("\n") for t in titles) state = {} def before(): """Whether the pull request was opened before this check landed on the base branch (read once). Until it is on the base branch at all, every pull request was.""" if "before" not in state: at = landed(base) o = opened() state["opened"] = "no event payload: taken as opened now" if o is None else when(o) if at is None: state["landed"] = None state["before"] = True else: sha, t = at state["landed"] = f"{when(t)}, {sha[:12]}" state["before"] = o is not None and o < t return state["before"] if by_hand and not before(): titles = "; ".join(f"`{t}`" for t in by_hand[:3]) + ("; ..." if len(by_hand) > 3 else "") bad.append(f"changelog: this change adds {len(by_hand)} entr{'y' if len(by_hand) == 1 else 'ies'} to the head of " f"`{UNRELEASED}` in {CHANGELOG} ({titles}). Since this check landed on the base branch ({state['landed']}; this pull request: " f"{state['opened']}), a change adds its entry as a file instead: move each to " f"{FRAGMENTS}/-.md, holding it exactly as the changelog would, and leave {CHANGELOG} as it was " f"(see {README}).") if bad: for b in bad: print(f"::error::{b}") return 1 if written: print(f"changelog: this change's entry is {', '.join(written)}") return 0 if release: print(f"changelog: this change renders {len(removed)} entr{'y' if len(removed) == 1 else 'ies'} of {FRAGMENTS}/ into {CHANGELOG} (a release)") return 0 if says_none(mb, head): print(f"changelog: a commit of this change says `{TRAILER}` in its trailers") return 0 if before(): if state["landed"] is None: print(f"changelog: this check is not on the base branch yet (no commit of the first-parent history of " f"{base[:12]} holds {SELF}), so this pull request counts as opened before it landed and passes as it is; " f"one opened after it lands adds its entry as a file of {FRAGMENTS}/") else: print(f"changelog: this pull request was opened ({state['opened']}) before this check landed on the base branch " f"({state['landed']}), so it passes as it is; one opened since adds its entry as a file of {FRAGMENTS}/") return 0 print(f"::error::changelog: this change touches {len(wai)} file(s) under wai/ ({', '.join(wai[:3])}{', ...' if len(wai) > 3 else ''}) " f"and adds no entry. Add one file, {FRAGMENTS}/-.md, holding the entry as the changelog " f"would (a `### ` title, a blank line, its text; see {README}), or, if there is nothing for the changelog " f"to say, end a commit message with the trailer line `{TRAILER}` (in its last paragraph, after a blank line, " f"with the other trailers).") return 1 def main(argv): if len(argv) != 2 or not argv[0] or not argv[1]: print("usage: check-changelog-fragment.py ") return 2 try: return check(*argv) except Fault as e: print(f"::error::changelog: {e}") return 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))