apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
German-first Promotional Text, Descriptions, Keywords and App Review notes, plus the app-specific privacy text the existing website policy is missing. Every character-limited field is checked by check-limits.py, which also catches headings whose stated count has drifted from the real length. Three things the brief assumed turned out not to hold, and the copy says so rather than shipping the claim: a Mac cannot act as a host, the published privacy policy covers only the website, and the App Review notes field caps at 4000 characters.
83 lines
2.7 KiB
Python
83 lines
2.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Check every App Store copy block in this directory against its field limit.
|
|
|
|
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
|
|
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
|
|
so `len()` on a `str` is the right measure — do not switch this to a byte count.
|
|
|
|
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
|
|
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
|
|
|
|
|
|
def blocks(text: str):
|
|
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
|
|
heading = None
|
|
buf: list[str] | None = None
|
|
for line in text.split("\n"):
|
|
if line.startswith("#") and buf is None:
|
|
heading = line.lstrip("#").strip()
|
|
if line.strip() == "```":
|
|
if buf is None:
|
|
buf = []
|
|
else:
|
|
yield heading or "", "\n".join(buf)
|
|
buf = None
|
|
continue
|
|
if buf is not None:
|
|
buf.append(line)
|
|
|
|
|
|
def kind_of(heading: str, body: str) -> str:
|
|
low = heading.lower()
|
|
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
|
|
return "KW"
|
|
if "template" in low:
|
|
return "NOTES"
|
|
return "DESC" if len(body) > 400 else "PROMO"
|
|
|
|
|
|
def main() -> int:
|
|
here = pathlib.Path(__file__).parent
|
|
failures = 0
|
|
stale = 0
|
|
for path in sorted(here.glob("*.md")):
|
|
found = list(blocks(path.read_text(encoding="utf-8")))
|
|
if not found:
|
|
continue
|
|
print(f"\n=== {path.name} ===")
|
|
for heading, body in found:
|
|
kind = kind_of(heading, body)
|
|
limit = LIMITS[kind]
|
|
n = len(body)
|
|
over = n > limit
|
|
failures += over
|
|
# Headings carry the count in parentheses; flag any that drifted from the real length.
|
|
claimed = re.search(r"\((\d+)\)\s*$", heading)
|
|
drift = ""
|
|
if claimed and int(claimed.group(1)) != n:
|
|
drift = f" [heading claims {claimed.group(1)}]"
|
|
stale += 1
|
|
status = "OVER" if over else "ok"
|
|
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
|
|
|
|
if failures:
|
|
print(f"\n{failures} block(s) OVER the limit")
|
|
elif stale:
|
|
print(f"\nAll within limits, but {stale} heading count(s) are stale")
|
|
else:
|
|
print("\nAll blocks within limits, all heading counts accurate")
|
|
return 1 if failures or stale else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|