Fix the 0.12 notes, and make the publish gate mean what it claims
`selfHosting` is a list of paragraphs, not a paragraph. Written as a string
it failed to decode -- and `ReleaseNotes.all` fails open, so that one field
emptied EVERY release on /releases, not just the new one. CI caught it.
The gate did not. `covers-android.py` says of itself that it is "the only
place that knows the schema", and it only ever asked whether some release
named the version. So it answered "Android 0.12 is covered by the release
dated 2026-09-07 (11 notes)" about a file that renders as a blank page --
which is precisely the outcome the gate exists to prevent.
It now validates what the renderer requires, across every release rather
than only the one being published: the failure is not local, an unrelated
older entry takes the new notes down with it. A shape the decoder would
reject exits 2 ("cannot tell"), never 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2039fed5c5
commit
2396276058
@@ -57,7 +57,9 @@
|
||||
"text": "The call screen is quieter — no boxed-in panel, no rings around the avatar."
|
||||
}
|
||||
],
|
||||
"selfHosting": "The device directory now serves the account's address alongside its devices. Clients need it to check a published key against the transparency log on any send that does not open a new session; without it they compute the pre-address subject, match no leaf, and refuse to send. Update the relay before telling anyone to update their app."
|
||||
"selfHosting": [
|
||||
"The device directory now serves the account's address alongside its devices. Clients need it to check a published key against the transparency log on any send that does not open a new session; without it they compute the pre-address subject, match no leaf, and refuse to send. Update the relay before telling anyone to update their app."
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
|
||||
@@ -25,6 +25,69 @@ 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
|
||||
# 2026-09-07: `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)
|
||||
@@ -51,6 +114,15 @@ def main(argv):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user