Files
Paul HittandClaude Opus 5 2b5db23716 Say what is true, without dating it
Comments, docs, scripts and pages kept narrating their own history: what
a limit was before a date, when a repository was split, which outage a
guard was born from, what a section used to say. None of it is what a
reader came for, and a date goes stale faster than the sentence holding
it.

Dates are gone from prose across Sources, Scripts, docs and the
hand-written pages, including the release-date parentheticals in
CONFIGURATION.md, where the version already identifies the build and an
operator acts on the version.

Kept, because they are data rather than narration: the `created_at`
example in the spec, the ISO-date field comment and fixtures in
ReleaseNotes, and the observed-log tables in IDENTIFYING-DATA.md and
METADATA-PASSES.md, where the dates are the measurements.

⚠️ /privacy is the one judgement call. It carried a dated notice that
the plaintext path was removed, and a paragraph explaining that it was
being said rather than quietly deleted, because anybody who read the old
version decided something on it. The date and the "this section used to
describe" framing are gone; the disclosure stays. Removing a privacy
change notice outright is the thing that paragraph exists to prevent.

697 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:19:33 -04:00

150 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Does the changelog say anything about this Android build?
Answers one question for the Android publish flow: is there a release in
`Resources/releases.json` that names this APK's versionName?
**This is the only place that knows the schema.** The Android repository
calls it rather than parsing the JSON itself, so a change to the release format
updates the checker in the same commit instead of silently turning a gate in
another repository into a rubber stamp.
**"Cannot tell" is not "yes".** A missing or malformed file exits 2, not 0.
The relay itself fails open on a bad changelog, a broken file costs a page, not
the site, but a publish gate that failed open would wave through exactly the
build whose notes nobody wrote.
covers-android.py <versionName> [releases.json]
0 a release names this version
1 no release names it
2 the question could not be answered
"""
import json
import os
import sys
# The renderer decodes the whole file or none of it. `ReleaseNotes.all`
# fails open on a decode error, deliberately, so a bad changelog costs a page
# rather than the site, which means ONE release with a wrong field type empties
# every release on /releases and nothing anywhere says so. A publish gate that
# only asked "is this version named?" therefore passed a file that renders as a
# blank page, which is the exact outcome the gate exists to prevent. Measured
# Seen once: `selfHosting` written as a string instead of a list of strings.
#
# Every release is checked, not only the one being published. The failure is
# not local, an unrelated older entry takes the new notes down with it.
REQUIRED = {"date": str, "changes": list}
OPTIONAL = {"headline": str, "selfHosting": list}
def validate(releases):
"""Every reason the Swift decoder would reject this file, in file order."""
problems = []
for index, release in enumerate(releases):
where = f"release {index}"
if not isinstance(release, dict):
problems.append(f"{where} is not an object")
continue
where = f"release {release.get('date', index)}"
for key, want in REQUIRED.items():
if key not in release:
problems.append(f"{where}: no {key!r}")
elif not isinstance(release[key], want):
problems.append(
f"{where}: {key!r} is {type(release[key]).__name__}, "
f"needs {want.__name__}")
for key, want in OPTIONAL.items():
if key in release and not isinstance(release[key], want):
problems.append(
f"{where}: {key!r} is {type(release[key]).__name__}, "
f"needs {want.__name__}")
# `selfHosting` is a list of paragraphs; a list of anything else
# decodes no better than a bare string.
for note in release.get("selfHosting") or []:
if not isinstance(note, str):
problems.append(f"{where}: a selfHosting note is not a string")
break
platforms = release.get("platforms")
if not isinstance(platforms, list) or not platforms:
problems.append(f"{where}: no platforms, so nobody knows what it is a release of")
else:
for platform in platforms:
if not isinstance(platform, dict) or "name" not in platform:
problems.append(f"{where}: a platform has no name")
elif not isinstance(platform["name"], str):
problems.append(f"{where}: a platform name is not a string")
elif "version" in platform and not isinstance(platform["version"], str):
problems.append(f"{where}: {platform['name']} version is not a string")
for change in release.get("changes") or []:
if not isinstance(change, dict):
problems.append(f"{where}: a change is not an object")
elif not isinstance(change.get("kind"), str) or not isinstance(change.get("text"), str):
problems.append(f"{where}: a change needs a string 'kind' and 'text'")
return problems
def main(argv):
if len(argv) < 2:
print(__doc__.strip(), file=sys.stderr)
return 2
wanted = argv[1].strip()
if not wanted:
print("no version given", file=sys.stderr)
return 2
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = argv[2] if len(argv) > 2 else os.path.join(here, "Resources", "releases.json")
try:
with open(path, encoding="utf-8") as handle:
releases = json.load(handle)
except FileNotFoundError:
print(f"no changelog at {path}", file=sys.stderr)
return 2
except (json.JSONDecodeError, OSError) as err:
print(f"changelog at {path} could not be read: {err}", file=sys.stderr)
return 2
if not isinstance(releases, list):
print(f"changelog at {path} is not a list of releases", file=sys.stderr)
return 2
problems = validate(releases)
if problems:
print(f"changelog at {path} will not render:", file=sys.stderr)
for problem in problems:
print(f" {problem}", file=sys.stderr)
print("\nThe page decodes the WHOLE file or none of it, so this empties\n"
"every release, not just the offending one.", file=sys.stderr)
return 2
for release in releases:
if not isinstance(release, dict):
continue
for platform in release.get("platforms") or []:
# The old schema had `platforms: ["android"]` with no version.
# A bare string cannot name a build, so it never counts as cover,
# otherwise an un-migrated file would silently pass every APK.
if not isinstance(platform, dict):
continue
if str(platform.get("name", "")).lower() != "android":
continue
if str(platform.get("version", "")).strip() == wanted:
date = release.get("date", "?")
changes = len(release.get("changes") or [])
print(f"Android {wanted} is covered by the release dated {date} "
f"({changes} note{'' if changes == 1 else 's'}).", file=sys.stderr)
return 0
print(f"No release in {path} names Android {wanted}.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))