blob: c3c2ec883cb4ec8e761725e6b94f73faff67dadb [file]
#!/usr/bin/env python3
"""Refresh and validate the offline Apache HugeGraph community roster."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import html.parser
import json
import os
import pathlib
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data" / "community"
ROSTER_PATH = DATA_DIR / "roster.json"
MAP_PATH = DATA_DIR / "github-map.json"
AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars"
PROJECT = "hugegraph"
SCHEMA_VERSION = 1
SOURCES = {
"committee": "https://whimsy.apache.org/public/committee-info.json",
"projects": "https://whimsy.apache.org/public/public_ldap_projects.json",
"people": "https://whimsy.apache.org/public/public_ldap_people.json",
}
JSON_LIMIT = 16 * 1024 * 1024
AVATAR_LIMIT = 5 * 1024 * 1024
ASF_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]*$")
GITHUB_LOGIN_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
AVATAR_PATH_PATTERN = re.compile(r"^/img/community/avatars/([0-9a-f]{64})\.webp$")
class RosterError(ValueError):
pass
def _read_json(path: pathlib.Path) -> dict:
try:
result = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RosterError(f"{path}: invalid JSON: {exc}") from exc
if not isinstance(result, dict):
raise RosterError(f"{path}: JSON root must be an object")
return result
def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> None:
try:
parsed = urllib.parse.urlparse(url)
port = parsed.port
except ValueError as exc:
raise RosterError(f"{kind}: malformed URL: {url}") from exc
if (
parsed.scheme != "https"
or parsed.hostname not in expected_hosts
or port not in (None, 443)
or parsed.username is not None
or parsed.password is not None
):
raise RosterError(f"{kind}: URL is not allowlisted: {url}")
class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, expected_hosts: set[str], kind: str):
super().__init__()
self.expected_hosts = expected_hosts
self.kind = kind
def redirect_request(self, request, fp, code, msg, headers, newurl):
_validate_remote_url(newurl, self.expected_hosts, self.kind)
return super().redirect_request(request, fp, code, msg, headers, newurl)
def _open_allowlisted(request: urllib.request.Request, expected_hosts: set[str], kind: str):
_validate_remote_url(request.full_url, expected_hosts, kind)
opener = urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind))
return opener.open(request, timeout=30)
def _read_bounded_response(response, *, expected_hosts: set[str], content_types: set[str], limit: int, kind: str) -> bytes:
final_url = response.geturl()
_validate_remote_url(final_url, expected_hosts, kind)
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type not in content_types and not (kind == "JSON source" and content_type.endswith("+json")):
raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}")
raw = response.read(limit + 1)
if len(raw) > limit:
raise RosterError(f"{kind}: response exceeds {limit} bytes")
return raw
def _fetch_json(url: str) -> dict:
request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"})
with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as response:
if response.status != 200:
raise RosterError(f"{url}: HTTP {response.status}")
raw = _read_bounded_response(
response,
expected_hosts={"whimsy.apache.org"},
content_types={"application/json"},
limit=JSON_LIMIT,
kind="JSON source",
)
try:
result = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RosterError(f"{url}: malformed JSON: {exc}") from exc
if not isinstance(result, dict):
raise RosterError(f"{url}: JSON root must be an object")
return result
def _person_name(people: dict, asf_id: str) -> str:
if not ASF_ID_PATTERN.fullmatch(asf_id):
raise RosterError(f"invalid ASF ID {asf_id!r}")
records = people.get("people")
if not isinstance(records, dict):
raise RosterError("people source must contain a people object")
record = records.get(asf_id)
name = record.get("name") if isinstance(record, dict) else None
if isinstance(name, list):
name = name[0] if name else ""
if not isinstance(name, str) or not name.strip():
raise RosterError(f"people source has no public name for ASF ID {asf_id!r}")
return name.strip()
def _initials(name: str) -> str:
parts = [part for part in name.replace("-", " ").split() if part]
return "".join(part[0].upper() for part in parts[:2]) or "?"
def _sort_key(asf_id: str, names: dict[str, str]) -> tuple[str, str]:
return names[asf_id].casefold(), asf_id.casefold()
def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict:
if data.get("schema_version") != SCHEMA_VERSION:
raise RosterError("github-map.json: schema_version must be 1")
mappings = data.get("mappings")
if not isinstance(mappings, dict):
raise RosterError("github-map.json: mappings must be an object")
public_names = data.get("public_names", {})
if not isinstance(public_names, dict):
raise RosterError("github-map.json: public_names must be an object")
for asf_id, public_name in public_names.items():
if not ASF_ID_PATTERN.fullmatch(asf_id):
raise RosterError(f"github-map.json: invalid public-name ASF ID {asf_id!r}")
if roster_ids is not None and asf_id not in roster_ids:
raise RosterError(f"github-map.json: unknown public-name ASF ID {asf_id!r}")
if not isinstance(public_name, str) or not public_name.strip():
raise RosterError(f"github-map.json: public name for {asf_id!r} must be non-empty")
display_order = data.get("display_order", {})
if not isinstance(display_order, dict):
raise RosterError("github-map.json: display_order must be an object")
for role, ordered_ids in display_order.items():
if role not in {"pmc", "committers"} or not isinstance(ordered_ids, list):
raise RosterError("github-map.json: display_order must contain PMC/Committers arrays")
if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in ordered_ids):
raise RosterError(f"github-map.json: display_order.{role} contains an invalid ASF ID")
if len(ordered_ids) != len(set(ordered_ids)):
raise RosterError(f"github-map.json: display_order.{role} contains duplicate ASF IDs")
if roster_ids is not None and any(asf_id not in roster_ids for asf_id in ordered_ids):
raise RosterError(f"github-map.json: display_order.{role} contains an unknown ASF ID")
logins: set[str] = set()
user_ids: set[int] = set()
for asf_id, mapping in mappings.items():
if not ASF_ID_PATTERN.fullmatch(asf_id):
raise RosterError(f"github-map.json: invalid ASF ID {asf_id!r}")
if roster_ids is not None and asf_id not in roster_ids:
raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}")
if not isinstance(mapping, dict):
raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object")
login, user_id = mapping.get("login"), mapping.get("user_id")
if not isinstance(login, str) or not login.strip() or login != login.strip():
raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login")
if not GITHUB_LOGIN_PATTERN.fullmatch(login) or "--" in login:
raise RosterError(f"github-map.json: invalid GitHub login {login!r}")
if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0:
raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id")
if login.casefold() in logins:
raise RosterError(f"github-map.json: duplicate GitHub login {login!r}")
if user_id in user_ids:
raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}")
logins.add(login.casefold())
user_ids.add(user_id)
return mappings
def _ordered_ids(ids: set[str], names: dict[str, str], mapping_data: dict, role: str) -> list[str]:
configured = mapping_data.get("display_order", {}).get(role, [])
if any(asf_id not in ids for asf_id in configured):
raise RosterError(f"github-map.json: display_order.{role} contains an ID outside its role")
configured_set = set(configured)
return list(configured) + sorted(ids - configured_set, key=lambda item: _sort_key(item, names))
def _webp_dimensions(raw: bytes) -> tuple[int, int]:
if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP":
raise RosterError("avatar is not a WebP image")
chunk = raw[12:16]
if chunk == b"VP8X":
return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little")
if chunk == b"VP8L":
bits = int.from_bytes(raw[21:25], "little")
return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)
if chunk == b"VP8 ":
marker = raw.find(b"\x9d\x01\x2a", 20)
if marker < 0 or marker + 7 > len(raw):
raise RosterError("avatar has an invalid VP8 frame")
width, height = struct.unpack_from("<HH", raw, marker + 3)
return width & 0x3FFF, height & 0x3FFF
raise RosterError(f"avatar uses unsupported WebP chunk {chunk!r}")
def _webp_chunk_kinds(raw: bytes) -> list[bytes]:
if len(raw) < 20 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP":
raise RosterError("avatar is not a WebP image")
if int.from_bytes(raw[4:8], "little") != len(raw) - 8:
raise RosterError("avatar has an invalid RIFF length")
kinds: list[bytes] = []
cursor = 12
while cursor + 8 <= len(raw):
kind = raw[cursor : cursor + 4]
size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little")
cursor += 8 + size + (size % 2)
if cursor > len(raw):
raise RosterError("avatar has a truncated WebP chunk")
kinds.append(kind)
if cursor != len(raw):
raise RosterError("avatar has trailing WebP data")
return kinds
def _validate_webp(raw: bytes, expected_dimensions: tuple[int, int] | None = None) -> tuple[int, int]:
dimensions = _webp_dimensions(raw)
kinds = _webp_chunk_kinds(raw)
if not any(kind in {b"VP8 ", b"VP8L"} for kind in kinds):
raise RosterError("avatar has no decodable WebP image bitstream")
if any(kind in {b"EXIF", b"XMP ", b"ICCP"} for kind in kinds):
raise RosterError("avatar contains metadata")
if expected_dimensions and dimensions != expected_dimensions:
raise RosterError(f"avatar dimensions are {dimensions}, expected {expected_dimensions}")
decoder = shutil.which("dwebp")
if not decoder:
raise RosterError("validating mapped avatars requires dwebp")
with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-decode-") as work:
source = pathlib.Path(work) / "avatar.webp"
target = pathlib.Path(work) / "avatar.ppm"
source.write_bytes(raw)
try:
result = subprocess.run(
[decoder, str(source), "-o", str(target)],
text=True,
capture_output=True,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RosterError(f"dwebp could not decode avatar: {exc}") from exc
if result.returncode or not target.is_file():
raise RosterError(f"dwebp rejected avatar: {result.stderr.strip()}")
return dimensions
def _strip_webp_metadata(raw: bytes) -> bytes:
"""Remove optional metadata chunks while preserving the image bitstream."""
_webp_dimensions(raw)
chunks: list[bytes] = []
cursor = 12
while cursor + 8 <= len(raw):
kind = raw[cursor : cursor + 4]
size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little")
end = cursor + 8 + size + (size % 2)
if end > len(raw):
raise RosterError("avatar has a truncated WebP chunk")
chunk = bytearray(raw[cursor:end])
if kind not in {b"EXIF", b"XMP ", b"ICCP"}:
if kind == b"VP8X":
chunk[8] &= ~0x2D
chunks.append(bytes(chunk))
cursor = end
if cursor != len(raw):
raise RosterError("avatar has trailing WebP data")
payload = b"WEBP" + b"".join(chunks)
return b"RIFF" + len(payload).to_bytes(4, "little") + payload
def _avatar_bytes(user_id: int) -> bytes:
request = urllib.request.Request(
f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4",
headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"},
)
with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub avatar") as response:
raw = _read_bounded_response(
response,
expected_hosts={"avatars.githubusercontent.com"},
content_types={"image/png", "image/jpeg", "image/webp"},
limit=AVATAR_LIMIT,
kind="GitHub avatar",
)
try:
raw = _strip_webp_metadata(raw)
except RosterError:
converter = shutil.which("cwebp")
if not converter:
raise RosterError("mapped avatars require cwebp when GitHub does not return WebP")
with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-") as work:
source = pathlib.Path(work) / "source"
target = pathlib.Path(work) / "avatar.webp"
source.write_bytes(raw)
try:
result = subprocess.run(
[converter, "-quiet", "-resize", "128", "128", "-metadata", "none", str(source), "-o", str(target)],
text=True,
capture_output=True,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {exc}") from exc
if result.returncode:
raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {result.stderr.strip()}")
raw = _strip_webp_metadata(target.read_bytes())
_validate_webp(raw, expected_dimensions=(128, 128))
return raw
def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict:
member = {
"asf_id": asf_id,
"name": name,
"initials": _initials(name),
"chair": chair,
"profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}",
}
if mapping:
member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]}
return member
def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict:
projects = projects_data.get("projects")
committees = committee_data.get("committees")
if not isinstance(projects, dict) or not isinstance(committees, dict):
raise RosterError("ASF sources must contain projects and committees objects")
project = projects.get(PROJECT)
committee = committees.get(PROJECT)
if not isinstance(project, dict) or not isinstance(committee, dict):
raise RosterError("ASF sources do not contain the HugeGraph project")
owners, members = project.get("owners"), project.get("members")
chair_map, committee_roster = committee.get("chair"), committee.get("roster")
if not isinstance(owners, list) or not isinstance(members, list):
raise RosterError("LDAP project owners/members must be arrays")
if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in owners + members):
raise RosterError("LDAP project owners/members contain an invalid ASF ID")
for field, asf_ids in (("owners", owners), ("members", members)):
if len(asf_ids) != len(set(asf_ids)):
raise RosterError(f"LDAP project {field} contains duplicate ASF IDs")
if not isinstance(chair_map, dict) or len(chair_map) != 1:
raise RosterError("committee source must name exactly one Chair")
if not isinstance(committee_roster, dict):
raise RosterError("committee roster must be an object")
if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in [*chair_map, *committee_roster]):
raise RosterError("committee source contains an invalid ASF ID")
owner_ids, member_ids = set(owners), set(members)
chair = next(iter(chair_map))
if not owner_ids <= member_ids:
raise RosterError("LDAP owners must be a subset of members")
if chair not in owner_ids:
raise RosterError("Chair must be an LDAP owner")
if owner_ids != set(committee_roster):
raise RosterError("committee roster and LDAP owners disagree")
mappings = _validate_mapping(mapping_data, member_ids)
names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids}
names.update({asf_id: mapping["login"] for asf_id, mapping in mappings.items()})
names.update({asf_id: public_name for asf_id, public_name in mapping_data.get("public_names", {}).items()})
pmc_ids = [chair] + _ordered_ids(owner_ids - {chair}, names, mapping_data, "pmc")
committer_ids = _ordered_ids(member_ids - owner_ids, names, mapping_data, "committers")
return {
"schema_version": SCHEMA_VERSION,
"project": PROJECT,
"retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"source": {
**SOURCES,
"chair": chair,
"owners": sorted(owner_ids),
"members": sorted(member_ids),
},
"roles": {
"pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids],
"committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids],
},
}
def _install_avatars(candidate: dict, target: pathlib.Path) -> None:
target.mkdir(parents=True, exist_ok=True)
for role in ("pmc", "committers"):
for member in candidate["roles"][role]:
github = member.get("github")
if not github:
continue
raw = _avatar_bytes(github["user_id"])
digest = hashlib.sha256(raw).hexdigest()
path = target / f"{digest}.webp"
if not path.exists():
path.write_bytes(raw)
member["avatar"] = f"/img/community/avatars/{path.name}"
member["profile_url"] = f"https://github.com/{github['login']}"
def validate_bundle(warn_after_days: int) -> list[str]:
_validate_repo_paths()
roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH)
if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT:
raise RosterError("roster.json: unsupported schema_version or project")
roles, source = roster.get("roles"), roster.get("source")
if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}:
raise RosterError("roster.json: roles must contain only pmc and committers")
if not isinstance(source, dict):
raise RosterError("roster.json: source must be an object")
for key, url in SOURCES.items():
if source.get(key) != url:
raise RosterError(f"roster.json: source.{key} is not authoritative")
owners, members, chair = source.get("owners"), source.get("members"), source.get("chair")
if not isinstance(owners, list) or not isinstance(members, list):
raise RosterError("roster.json: source owners/members must be arrays")
if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in owners + members):
raise RosterError("roster.json: source owners/members contain an invalid ASF ID")
if owners != sorted(set(owners)) or members != sorted(set(members)):
raise RosterError("roster.json: source owners/members must be sorted and unique")
if not set(owners) <= set(members) or chair not in owners:
raise RosterError("roster.json: invalid owners/members/Chair relationship")
if not isinstance(chair, str) or not ASF_ID_PATTERN.fullmatch(chair):
raise RosterError("roster.json: source Chair must be a valid ASF ID")
pmc, committers = roles["pmc"], roles["committers"]
if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc:
raise RosterError("roster.json: invalid role arrays")
people = pmc + committers
for person in people:
if not isinstance(person, dict):
raise RosterError("roster.json: every role entry must be an object")
asf_id = person.get("asf_id")
if not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id):
raise RosterError("roster.json: role entry has an invalid ASF ID")
name = person.get("name")
if not isinstance(name, str) or not name.strip():
raise RosterError(f"roster.json: member name must be non-empty for {asf_id!r}")
if person.get("initials") != _initials(name):
raise RosterError(f"roster.json: member initials mismatch for {asf_id!r}")
if not isinstance(person.get("profile_url"), str):
raise RosterError(f"roster.json: member profile URL must be a string for {asf_id!r}")
if type(person.get("chair")) is not bool:
raise RosterError(f"roster.json: chair must be boolean for {asf_id!r}")
ids = [person["asf_id"] for person in people]
if len(ids) != len(set(ids)) or set(ids) != set(members):
raise RosterError("roster.json: members must match unique source ASF IDs")
if {p["asf_id"] for p in pmc} != set(owners):
raise RosterError("roster.json: PMC must equal owners")
if {p["asf_id"] for p in committers} != set(members) - set(owners):
raise RosterError("roster.json: Committers must equal members minus owners")
chairs = [person for person in people if person.get("chair") is True]
if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]:
raise RosterError("roster.json: unique Chair must be first in PMC")
mappings = _validate_mapping(mapping, set(ids))
names = {person["asf_id"]: person["name"] for person in people}
expected_pmc = [chair] + _ordered_ids(set(owners) - {chair}, names, mapping, "pmc")
expected_committers = _ordered_ids(set(members) - set(owners), names, mapping, "committers")
if [person["asf_id"] for person in pmc] != expected_pmc:
raise RosterError("roster.json: PMC order does not match display_order or sorted by public name")
if [person["asf_id"] for person in committers] != expected_committers:
raise RosterError("roster.json: Committers order does not match display_order or sorted by public name")
public_names = mapping.get("public_names", {})
for person in people:
if person["asf_id"] in public_names and person["name"] != public_names[person["asf_id"]]:
raise RosterError(f"roster.json: public name drift for {person['asf_id']!r}")
expected, avatar = mappings.get(person["asf_id"]), person.get("avatar")
if expected != person.get("github"):
raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}")
if expected:
match = AVATAR_PATH_PATTERN.fullmatch(avatar) if isinstance(avatar, str) else None
if not match:
raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar")
filename = f"{match.group(1)}.webp"
path = AVATAR_DIR / filename
if path.is_symlink():
raise RosterError(f"roster.json: avatar must not be a symlink {avatar}")
raw = path.read_bytes()
if hashlib.sha256(raw).hexdigest() != match.group(1):
raise RosterError(f"roster.json: invalid avatar {avatar}")
_validate_webp(raw, expected_dimensions=(128, 128))
if person["profile_url"] != f"https://github.com/{expected['login']}":
raise RosterError(f"roster.json: mapped profile URL mismatch")
elif avatar:
raise RosterError(f"roster.json: unmapped member has an avatar")
elif person["profile_url"] != f"https://people.apache.org/phonebook.html?uid={person['asf_id']}":
raise RosterError(f"roster.json: unmapped profile URL mismatch for {person['asf_id']!r}")
retrieved_at = roster.get("retrieved_at")
if not isinstance(retrieved_at, str):
raise RosterError("roster.json: retrieved_at must be an ISO-8601 UTC string")
try:
retrieved = dt.datetime.fromisoformat(retrieved_at.replace("Z", "+00:00"))
except (KeyError, TypeError, ValueError) as exc:
raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc
now = dt.datetime.now(dt.timezone.utc)
if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5):
raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone")
age = now - retrieved
return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else []
class _CommunityLinkParser(html.parser.HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.role_stack: list[str | None] = []
self.section_order: list[str] = []
self.links = {"pmc": [], "committers": []}
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = dict(attrs)
if tag == "section":
role = attributes.get("data-community-role")
if role in self.links:
self.section_order.append(role)
self.role_stack.append(role if role in self.links else None)
elif tag == "a" and self.role_stack and self.role_stack[-1]:
href = attributes.get("href")
if href:
self.links[self.role_stack[-1]].append(href)
def handle_endtag(self, tag: str) -> None:
if tag == "section" and self.role_stack:
self.role_stack.pop()
def _rendered_role_links(rendered: str, html_output: bool) -> dict[str, list[str]]:
if html_output:
parser = _CommunityLinkParser()
parser.feed(rendered)
if parser.section_order != ["pmc", "committers"]:
raise RosterError("Community role section order drift")
return parser.links
starts = {}
for role, heading in (("pmc", "PMC"), ("committers", "Committers")):
match = re.search(rf"(?m)^### {heading}\s*$", rendered)
if not match:
return {"pmc": [], "committers": []}
starts[role] = match.end()
if starts["pmc"] >= starts["committers"]:
return {"pmc": [], "committers": []}
tail = rendered[starts["committers"] :]
next_heading = re.search(r"(?m)^##\s", tail)
segments = {
"pmc": rendered[starts["pmc"] : starts["committers"]],
"committers": tail[: next_heading.start()] if next_heading else tail,
}
return {
role: re.findall(r"(?m)^-\s+\[[^\]]+\]\(([^)\s]+)\)", segment)
for role, segment in segments.items()
}
def validate_rendered_outputs(destination: pathlib.Path) -> None:
expected = {
"community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
"_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
"community/index.md": ("## Project members", "### PMC", "### Committers"),
"cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
"cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'),
"cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"),
}
roster_roles = _read_json(ROSTER_PATH)["roles"]
for relative, markers in expected.items():
path = destination / relative
if not path.is_file():
raise RosterError(f"rendered output is missing {relative}")
rendered = path.read_text(encoding="utf-8")
if relative.endswith(".html"):
has_markers = all(
re.search(rf'data-community-role=(?:"{role}"|{role})(?:\s|>)', rendered)
for role in ("pmc", "committers")
)
else:
has_markers = all(marker in rendered for marker in markers)
if not has_markers:
raise RosterError(f"rendered output {relative} is missing Community markers")
rendered_links = _rendered_role_links(rendered, relative.endswith(".html"))
for role, entries in roster_roles.items():
# Only reviewed GitHub mappings are actionable links. Unmapped
# ASF members remain visible as static identity cards.
expected_links = [person["profile_url"] for person in entries if person.get("github")]
if rendered_links[role] != expected_links:
raise RosterError(f"rendered output {relative} has {role} link parity drift")
def _atomic_write(path: pathlib.Path, raw: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary_path = pathlib.Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, path)
finally:
if temporary_path.exists():
temporary_path.unlink()
def _unlink(path: pathlib.Path) -> None:
path.unlink()
def _copy_candidate(raw: bytes, destination: pathlib.Path) -> None:
# An interrupted refresh may leave the temporary path behind. Remove only
# that exact path (including a symlink) before recreating it exclusively.
if destination.exists() or destination.is_symlink():
destination.unlink()
with destination.open("xb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
def _validate_avatar_blob(name: str, raw: bytes) -> None:
match = re.fullmatch(r"([0-9a-f]{64})\.webp", name)
if not match or hashlib.sha256(raw).hexdigest() != match.group(1):
raise RosterError(f"candidate avatar name/hash mismatch: {name}")
_validate_webp(raw, expected_dimensions=(128, 128))
def _assert_repo_path(path: pathlib.Path, label: str) -> None:
root = ROOT.absolute()
candidate = path.absolute()
try:
relative = candidate.relative_to(root)
except ValueError as exc:
raise RosterError(f"{label} must stay inside the repository") from exc
current = root
if current.is_symlink():
raise RosterError("repository root must not be a symlink")
for part in relative.parts:
current /= part
if current.is_symlink():
raise RosterError(f"{label} must not contain symlink path components")
try:
candidate.resolve(strict=False).relative_to(root.resolve(strict=True))
except (OSError, ValueError) as exc:
raise RosterError(f"{label} resolves outside the repository") from exc
def _validate_repo_paths() -> None:
_assert_repo_path(DATA_DIR, "community data directory")
_assert_repo_path(ROSTER_PATH, "community roster")
_assert_repo_path(MAP_PATH, "GitHub mapping")
_assert_repo_path(AVATAR_DIR, "community avatar directory")
def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> None:
"""Install verified immutable assets, then atomically publish the roster."""
_validate_repo_paths()
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
referenced = {
pathlib.PurePosixPath(person["avatar"]).name
for role in candidate["roles"].values()
for person in role
if person.get("avatar")
}
existing = {path.name: path for path in AVATAR_DIR.glob("*.webp")}
for name, avatar in sorted(candidate_avatars.items()):
_validate_avatar_blob(name, avatar)
destination = AVATAR_DIR / name
if destination.exists() or destination.is_symlink():
try:
if destination.is_symlink():
raise RosterError(f"candidate destination is a symlink: {destination}")
_validate_avatar_blob(name, destination.read_bytes())
continue
except RosterError:
pass
staged = AVATAR_DIR / f".{name}.candidate"
try:
_copy_candidate(avatar, staged)
os.replace(staged, destination)
finally:
if staged.exists():
_unlink(staged)
raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + "\n").encode()
# This atomic replace is the commit point. Failures before it leave the
# last-good roster selected; installed content-addressed assets are safe
# unreferenced candidates.
_atomic_write(ROSTER_PATH, raw)
for name in sorted(set(existing) - referenced):
try:
_unlink(AVATAR_DIR / name)
except OSError as exc:
print(
f"::warning file=static/img/community/avatars/{name}::"
f"could not remove unreferenced avatar: {exc}",
file=sys.stderr,
)
def refresh() -> None:
_validate_repo_paths()
DATA_DIR.mkdir(parents=True, exist_ok=True)
source_data = {key: _fetch_json(url) for key, url in SOURCES.items()}
candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH))
work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", dir=DATA_DIR))
try:
candidate_avatars = work / "avatars"
_install_avatars(candidate, candidate_avatars)
avatar_bytes = {path.name: path.read_bytes() for path in candidate_avatars.glob("*.webp")}
finally:
# Candidate cleanup is deliberately completed before the checked-in
# bundle changes, so cleanup failure cannot publish a new roster.
shutil.rmtree(work)
_commit_bundle(candidate, avatar_bytes)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("refresh")
validate = commands.add_parser("validate")
validate.add_argument("--warn-after-days", type=int, default=90)
validate.add_argument("--artifact", type=pathlib.Path, help="validate a prebuilt Hugo artifact")
args = parser.parse_args()
try:
if args.command == "refresh":
refresh()
else:
if args.warn_after_days < 0:
raise RosterError("--warn-after-days must be non-negative")
for warning in validate_bundle(args.warn_after_days):
print(f"::warning file=data/community/roster.json::{warning}")
if args.artifact:
validate_rendered_outputs(args.artifact.resolve())
except (OSError, RosterError, urllib.error.URLError) as exc:
print(f"community roster: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())