open-standards/scripts/check-changelog-fragment.py
Transaction Science 946f3beda9
All checks were successful
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 6s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 7s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 6s
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 6s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 6s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 6s
WAI operation catalogue / does this change touch it (pull_request) Successful in 6s
WAI conformance / does this change touch it (pull_request) Successful in 6s
WAI energy receipts / does this change touch it (pull_request) Successful in 5s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 6s
wai-fleet service / does this change touch it (pull_request) Successful in 6s
WAI JPEG-AI whole decode (pinned inputs, required) / does this change touch it (pull_request) Successful in 7s
wai-ledger service / does this change touch it (pull_request) Successful in 6s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m25s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 6s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 6s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 6s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 6s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 6s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m57s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m7s
WAI Studio / does this change touch it (pull_request) Successful in 6s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 6s
wai-web wasm32 / does this change touch it (pull_request) Successful in 6s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 29s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 2m1s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 2m59s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m1s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 26s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m19s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 16s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m4s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 1m15s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 5m15s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 6m19s
wai-fleet service / service · macos (pull_request) Successful in 1m2s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 2m46s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 1m43s
wai-ledger service / service · macos (pull_request) Successful in 1m24s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 1m2s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 4m18s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 9m19s
wai-pack / wai-pack · macos (pull_request) Successful in 3m8s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 30s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 7m49s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 3m23s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m39s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m9s
wai-fleet service / service · linux (pull_request) Successful in 2m5s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 3m28s
wai-ledger service / service · linux (pull_request) Successful in 1m43s
wai-pack / wai-pack · linux (pull_request) Successful in 1m55s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 3m0s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m10s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 16m12s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 11m32s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 4m51s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 13m12s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 15m2s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 4m3s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 16m34s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m40s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 17m22s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m44s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 5m46s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 34s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 17s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 9m28s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m7s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 14m49s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 4m34s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 2m16s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 11m4s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 13m6s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 5m58s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 10m55s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 9m51s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 10m15s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 9m21s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 30m14s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h4m7s
Changelog entries and the conformance workflow's lists move into files a change adds
Every change to WAI added an entry at the head of wai/CHANGELOG.md, and every new module
appended to the hand-kept lists of .github/workflows/wai-conformance.yml. So any two
branches conflicted on those lines and had to merge one after another. This moves both into
files a change adds.

- Changelog entries: a change adds wai/changes/<yyyy-mm-dd>-<slug>.md holding the entry
  text. scripts/changelog-assemble.py renders the entries into CHANGELOG.md's Unreleased
  section for a release, in a stable order. scripts/check-changelog-fragment.py, run by
  repo-checks on every pull request, fails a pull request that changes wai/ and adds or
  edits no entry file directly in wai/changes/ (a release passes, and so does a commit
  with the trailer line "Changelog: none"). A pull request opened before the check reached
  main passes as it is; one opened after it that adds its entry to the head of Unreleased
  fails and is told where the entry goes. wai breaking reads breaking changes from entry
  files too.
- The conformance workflow: each per-module verifier step is a file in
  .github/wai-conformance/verifiers/, each corpus a file in corpora/, and the
  integer-kernel tests carry a tag line. scripts/wai-conformance-lists.py derives every
  list the workflow kept by hand (WAI_FEATURES, the union --bin and --test lists, the
  features-alone lists, the kernel and corpus steps) from those files when the workflow
  runs. Its `selftest --exact` holds the derived lists to the hand lists of the commit
  before this one, and its dry-run prints every command each job runs: the dry-runs before
  and after this change differ only in the gate's path list, which gains the two paths this
  change adds.
- What stays shared, and how to insert there, is written in wai/CONTRIBUTING.md and
  wai/changes/README.md: wai-rs Cargo.toml features and [[test]] entries, lib.rs, registry
  rows, wai/.gitattributes and the live corpus table take an insert beside the related
  entry, never at the end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:41:29 -05:00

310 lines
14 KiB
Python
Executable file

#!/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 <base-sha> <head-sha>
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 <base-sha> (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}/<yyyy-mm-dd>-<slug>.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}/<yyyy-mm-dd>-<slug>.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}/<yyyy-mm-dd>-<slug>.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 <base-sha> <head-sha>")
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:]))