imbajin commented on code in PR #493:
URL: https://github.com/apache/hugegraph-doc/pull/493#discussion_r4049337162


##########
layouts/_partials/community/members.html:
##########
@@ -0,0 +1,41 @@
+{{- $page := .page -}}
+{{- $data := hugo.Data.community.roster -}}
+{{- $labels := dict
+  "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC 
members and Committers, sourced from public ASF records." "chair" "Chair" "pmc" 
"PMC" "committers" "Committers")
+  "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 
Committers,数据来自 ASF 公开记录。" "chair" "主席" "pmc" "PMC" "committers" "Committers")
+-}}
+{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") 
-}}
+{{- $style := resources.Get "scss/community-members.scss" | toCSS | minify | 
fingerprint -}}
+<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ 
$style.Data.Integrity }}" crossorigin="anonymous">
+<section id="project-members" class="td-landing-section hg-community-members" 
aria-labelledby="project-members-title">
+  <div class="td-site-container">
+    <div class="td-landing-section__header hg-community-members__header">
+      <h2 id="project-members-title">{{ $copy.title }}</h2>
+      <p>{{ $copy.lead }}</p>
+    </div>
+    {{- range $role := slice "pmc" "committers" }}
+      {{- $members := index $data.roles $role }}
+      <section class="hg-community-members__role" data-community-role="{{ 
$role }}" aria-labelledby="project-members-{{ $role }}">
+        <h3 id="project-members-{{ $role }}">{{ index $copy $role }}</h3>
+        <ul class="td-landing-contributor-grid hg-community-members__grid" 
role="list">
+          {{- range $members }}
+          <li class="td-landing-contributor hg-community-member">
+            {{- $hasGithub := .github -}}
+            {{- if $hasGithub }}
+            <a class="hg-community-member__surface hg-community-member__link" 
href="{{ .profile_url }}" target="_blank" rel="noopener noreferrer" 
aria-label="{{ .name }} on GitHub">

Review Comment:
   Addressed in d25c82d75: Community accessible names now use localized copy, 
with EN “on GitHub” and CN “的 GitHub 主页”; the Chromium contract covers both 
locales.



##########
content/en/docs/clients/restful-api/vertex.md:
##########
@@ -5,7 +5,7 @@ weight: 7
 description: "Vertex REST API: Create, query, update, and delete vertex data 
in the graph with support for batch operations and conditional filtering."
 ---
 
-### 2.1 Vertex
+## 2.1 Vertex {#vertex-api}

Review Comment:
   Addressed in d25c82d75: the EN and CN 2.1.x sections are h3 and their nested 
Method/Request/Response blocks are h4, preserving sequential heading hierarchy.



##########
scripts/test_community_roster.py:
##########
@@ -0,0 +1,785 @@
+import hashlib
+import importlib.util
+import json
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest import mock
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / 
"scripts" / "community_roster.py")
+roster = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader
+SPEC.loader.exec_module(roster)
+
+
+class FakeResponse:
+    def __init__(self, raw, *, url, content_type, status=200):
+        self.raw = raw
+        self.url = url
+        self.headers = {"Content-Type": content_type}
+        self.status = status
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *_args):
+        return False
+
+    def geturl(self):
+        return self.url
+
+    def read(self, limit=-1):
+        return self.raw if limit < 0 else self.raw[:limit]
+
+
+class CommunityRosterTests(unittest.TestCase):
+    def fixture(self):
+        return (
+            {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair 
Person"}}, "roster": {"chair": {}, "zeta": {}}}}},
+            {"projects": {"hugegraph": {"owners": ["zeta", "chair"], 
"members": ["other", "zeta", "chair"]}}},
+            {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": 
"Alpha Owner"}, "other": {"name": "Beta Committer"}}},
+            {"schema_version": 1, "mappings": {}},
+        )
+
+    def test_build_roster_derives_roles_and_order(self):
+        candidate = roster.build_roster(*self.fixture())
+        self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in 
candidate["roles"]["pmc"]])
+        self.assertEqual(["other"], [p["asf_id"] for p in 
candidate["roles"]["committers"]])
+        self.assertTrue(candidate["roles"]["pmc"][0]["chair"])
+
+    def test_same_names_use_asf_id_tiebreaker_across_hash_seeds(self):
+        program = f"""
+import importlib.util, json
+spec = importlib.util.spec_from_file_location("community_roster", {str(ROOT / 
"scripts/community_roster.py")!r})
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+committee = {{"committees": {{"hugegraph": {{"chair": {{"chair": {{}}}}, 
"roster": {{"chair": {{}}, "zeta": {{}}, "alpha": {{}}}}}}}}}}
+projects = {{"projects": {{"hugegraph": {{"owners": ["zeta", "chair", 
"alpha"], "members": ["zeta", "chair", "alpha"]}}}}}}
+people = {{"people": {{"chair": {{"name": "Chair"}}, "zeta": {{"name": "Same 
Name"}}, "alpha": {{"name": "Same Name"}}}}}}
+result = module.build_roster(committee, projects, people, {{"schema_version": 
1, "mappings": {{}}}})
+print(json.dumps([person["asf_id"] for person in result["roles"]["pmc"]]))
+"""
+        outputs = []
+        for seed in ("1", "777"):
+            environment = {**os.environ, "PYTHONHASHSEED": seed}
+            outputs.append(subprocess.check_output([sys.executable, "-c", 
program], env=environment, text=True))
+        self.assertEqual(outputs[0], outputs[1])
+        self.assertEqual(["chair", "alpha", "zeta"], json.loads(outputs[0]))
+
+    def test_build_roster_rejects_committee_ldap_drift(self):
+        committee, projects, people, mapping = self.fixture()
+        committee["committees"]["hugegraph"]["roster"].pop("zeta")
+        with self.assertRaisesRegex(roster.RosterError, "disagree"):
+            roster.build_roster(committee, projects, people, mapping)
+
+    def test_build_roster_rejects_duplicate_ldap_ids(self):
+        for field in ("owners", "members"):
+            with self.subTest(field=field):
+                committee, projects, people, mapping = self.fixture()
+                projects["projects"]["hugegraph"][field].append(
+                    projects["projects"]["hugegraph"][field][0]
+                )
+                with self.assertRaisesRegex(
+                    roster.RosterError,
+                    rf"LDAP project {field} contains duplicate ASF IDs",
+                ):
+                    roster.build_roster(committee, projects, people, mapping)
+
+    def test_mapping_requires_unique_numeric_ids(self):
+        mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", 
"user_id": 1}, "two": {"login": "other", "user_id": 1}}}
+        with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub 
user_id"):
+            roster._validate_mapping(mapping, {"one", "two"})
+
+    def test_mapping_rejects_invalid_identity_characters(self):
+        with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"):
+            roster._validate_mapping(
+                {"schema_version": 1, "mappings": {"Bad ID": {"login": 
"valid", "user_id": 1}}},
+                {"Bad ID"},
+            )
+        with self.assertRaisesRegex(roster.RosterError, "invalid GitHub 
login"):
+            roster._validate_mapping(
+                {"schema_version": 1, "mappings": {"valid": {"login": 
"bad/login", "user_id": 1}}},
+                {"valid"},
+            )
+
+    def test_avatar_metadata_is_stripped(self):
+        vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes([0x2D]) + b"\0" * 9
+        exif = b"EXIF" + (4).to_bytes(4, "little") + b"meta"
+        iccp = b"ICCP" + (4).to_bytes(4, "little") + b"icc!"
+        payload = b"WEBP" + vp8x + exif + iccp
+        raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload
+        stripped = roster._strip_webp_metadata(raw)
+        self.assertNotIn(b"EXIF", stripped)
+        self.assertNotIn(b"ICCP", stripped)
+        self.assertEqual(0, stripped[20] & 0x2D)
+
+    def test_truncated_vp8x_without_image_bitstream_is_rejected(self):
+        vp8x = b"VP8X" + (10).to_bytes(4, "little") + b"\0" * 10
+        payload = b"WEBP" + vp8x
+        raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload
+        with self.assertRaisesRegex(roster.RosterError, "no decodable"):
+            roster._validate_webp(raw)
+
+    def test_network_response_contracts_are_bounded_and_allowlisted(self):
+        with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
+            roster._read_bounded_response(
+                FakeResponse(b"{}", url="https://evil.example/data";, 
content_type="application/json"),
+                expected_hosts={"whimsy.apache.org"},
+                content_types={"application/json"},
+                limit=10,
+                kind="JSON source",
+            )
+        with self.assertRaisesRegex(roster.RosterError, "Content-Type"):
+            roster._read_bounded_response(
+                FakeResponse(b"{}", url="https://whimsy.apache.org/data";, 
content_type="text/html"),
+                expected_hosts={"whimsy.apache.org"},
+                content_types={"application/json"},
+                limit=10,
+                kind="JSON source",
+            )
+        with self.assertRaisesRegex(roster.RosterError, "exceeds"):
+            roster._read_bounded_response(
+                FakeResponse(b"x" * 11, url="https://whimsy.apache.org/data";, 
content_type="application/json"),
+                expected_hosts={"whimsy.apache.org"},
+                content_types={"application/json"},
+                limit=10,
+                kind="JSON source",
+            )
+
+    def test_redirect_is_rejected_before_following_disallowed_host(self):
+        handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, 
"JSON source")
+        with self.assertRaisesRegex(roster.RosterError, "not allowlisted"):
+            handler.redirect_request(
+                mock.Mock(),
+                None,
+                302,
+                "Found",
+                {},
+                "http://127.0.0.1/private";,
+            )
+
+    def test_malformed_json_and_encoder_timeout_are_roster_errors(self):
+        response = FakeResponse(
+            b"{bad",
+            url="https://whimsy.apache.org/public/committee-info.json";,
+            content_type="application/json",
+        )
+        with mock.patch.object(roster, "_open_allowlisted", 
return_value=response):
+            with self.assertRaisesRegex(roster.RosterError, "malformed JSON"):
+                roster._fetch_json(roster.SOURCES["committee"])
+        avatar = FakeResponse(
+            b"not-an-image",
+            url="https://avatars.githubusercontent.com/u/1?s=128&v=4";,
+            content_type="image/png",
+        )
+        with mock.patch.object(roster, "_open_allowlisted", 
return_value=avatar), \
+             mock.patch.object(roster.shutil, "which", 
return_value="/fake/cwebp"), \
+             mock.patch.object(roster.subprocess, "run", 
side_effect=subprocess.TimeoutExpired("cwebp", 20)):
+            with self.assertRaisesRegex(roster.RosterError, "cwebp failed"):
+                roster._avatar_bytes(1)
+
+    def test_nested_source_schema_errors_are_roster_errors(self):
+        committee, projects, people, mapping = self.fixture()
+        projects["projects"] = []
+        with self.assertRaisesRegex(roster.RosterError, "projects and 
committees objects"):
+            roster.build_roster(committee, projects, people, mapping)
+        committee, projects, people, mapping = self.fixture()
+        projects["projects"]["hugegraph"]["owners"] = [[]]
+        with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"):
+            roster.build_roster(committee, projects, people, mapping)
+        with tempfile.TemporaryDirectory(prefix="community-json-root-") as 
directory:
+            path = pathlib.Path(directory) / "array.json"
+            path.write_text("[]")
+            with self.assertRaisesRegex(roster.RosterError, "JSON root must be 
an object"):
+                roster._read_json(path)
+
+    def test_checked_in_bundle_validates(self):
+        self.assertEqual([], roster.validate_bundle(90))
+
+    def test_unmapped_profile_must_be_exact_phonebook_url(self):
+        with tempfile.TemporaryDirectory(prefix="community-profile-test-") as 
directory:
+            root = pathlib.Path(directory)
+            candidate = json.loads(roster.ROSTER_PATH.read_text())
+            candidate["roles"]["committers"][0]["profile_url"] = 
"https://example.invalid/profile";
+            roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+            roster_path.write_text(json.dumps(candidate))
+            map_path.write_text(roster.MAP_PATH.read_text())
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
+                 mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                with self.assertRaisesRegex(roster.RosterError, "unmapped 
profile URL mismatch"):
+                    roster.validate_bundle(90)
+
+    def test_chair_values_must_be_strict_booleans(self):
+        with tempfile.TemporaryDirectory(prefix="community-chair-test-") as 
directory:
+            root = pathlib.Path(directory)
+            candidate = json.loads(roster.ROSTER_PATH.read_text())
+            candidate["roles"]["committers"][0]["chair"] = 0
+            roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+            roster_path.write_text(json.dumps(candidate))
+            map_path.write_text(roster.MAP_PATH.read_text())
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
+                 mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                with self.assertRaisesRegex(roster.RosterError, "chair must be 
boolean"):
+                    roster.validate_bundle(90)
+
+    def test_avatar_path_rejects_extra_segments_and_symlinks(self):
+        base = json.loads(roster.ROSTER_PATH.read_text())
+        asf_id = base["roles"]["committers"][0]["asf_id"]
+        mapping = {"schema_version": 1, "mappings": {asf_id: {"login": 
"valid-user", "user_id": 1}}}
+        for avatar in (
+            "/img/community/avatars/extra/" + "a" * 64 + ".webp",
+            "/img/community/avatars/../" + "a" * 64 + ".webp",
+        ):
+            with self.subTest(avatar=avatar), 
tempfile.TemporaryDirectory(prefix="community-avatar-path-") as directory:
+                root = pathlib.Path(directory)
+                candidate = json.loads(json.dumps(base))
+                member = next(p for p in candidate["roles"]["committers"] if 
p["asf_id"] == asf_id)
+                member.update(github=mapping["mappings"][asf_id], 
avatar=avatar, profile_url="https://github.com/valid-user";)
+                roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+                roster_path.write_text(json.dumps(candidate))
+                map_path.write_text(json.dumps(mapping))
+                with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                     mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", root), \
+                     mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                    with self.assertRaisesRegex(roster.RosterError, "needs a 
local avatar"):
+                        roster.validate_bundle(90)
+        with tempfile.TemporaryDirectory(prefix="community-avatar-link-") as 
directory:
+            root = pathlib.Path(directory)
+            avatar_dir = root / "avatars"
+            avatar_dir.mkdir()
+            raw = b"target"
+            digest = hashlib.sha256(raw).hexdigest()
+            target = root / "target.webp"
+            target.write_bytes(raw)
+            (avatar_dir / f"{digest}.webp").symlink_to(target)
+            candidate = json.loads(json.dumps(base))
+            member = next(p for p in candidate["roles"]["committers"] if 
p["asf_id"] == asf_id)
+            member.update(
+                github=mapping["mappings"][asf_id],
+                avatar=f"/img/community/avatars/{digest}.webp",
+                profile_url="https://github.com/valid-user";,
+            )
+            roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+            roster_path.write_text(json.dumps(candidate))
+            map_path.write_text(json.dumps(mapping))
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_dir):
+                with self.assertRaisesRegex(roster.RosterError, "must not be a 
symlink"):
+                    roster.validate_bundle(90)
+
+    def test_avatar_directory_parent_symlink_is_rejected(self):
+        with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as 
directory:
+            root = pathlib.Path(directory)
+            outside = root / "outside"
+            outside.mkdir()
+            avatar_link = root / "static" / "img" / "community" / "avatars"
+            avatar_link.parent.mkdir(parents=True)
+            avatar_link.symlink_to(outside, target_is_directory=True)
+            with mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root / "data" / 
"community"), \
+                 mock.patch.object(roster, "ROSTER_PATH", root / "data" / 
"community" / "roster.json"), \
+                 mock.patch.object(roster, "MAP_PATH", root / "data" / 
"community" / "github-map.json"), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_link):
+                with self.assertRaisesRegex(roster.RosterError, "symlink path 
components"):
+                    roster._validate_repo_paths()
+
+    def test_member_name_and_initials_must_be_non_empty_and_derived(self):
+        base = json.loads(roster.ROSTER_PATH.read_text())
+        mapping = json.loads(roster.MAP_PATH.read_text())
+        for field, value, message in (
+            ("name", "", "name must be non-empty"),
+            ("initials", "", "initials mismatch"),
+        ):
+            with self.subTest(field=field), 
tempfile.TemporaryDirectory(prefix="community-identity-") as directory:
+                root = pathlib.Path(directory)
+                candidate = json.loads(json.dumps(base))
+                candidate["roles"]["committers"][0][field] = value
+                roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+                roster_path.write_text(json.dumps(candidate))
+                map_path.write_text(json.dumps(mapping))
+                with mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", root), \
+                     mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                     mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                    with self.assertRaisesRegex(roster.RosterError, message):
+                        roster.validate_bundle(90)
+
+    def test_local_roster_schema_errors_are_roster_errors(self):
+        base = json.loads(roster.ROSTER_PATH.read_text())
+        mapping = json.loads(roster.MAP_PATH.read_text())
+        mutations = (
+            ("asf_id", [], "invalid ASF ID"),
+            ("name", 123, "name must be non-empty"),
+            ("retrieved_at", None, "ISO-8601 UTC string"),
+        )
+        for field, value, message in mutations:
+            with self.subTest(field=field), 
tempfile.TemporaryDirectory(prefix="community-schema-") as directory:
+                root = pathlib.Path(directory)
+                candidate = json.loads(json.dumps(base))
+                if field == "retrieved_at":
+                    candidate[field] = value
+                else:
+                    candidate["roles"]["committers"][0][field] = value
+                roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+                roster_path.write_text(json.dumps(candidate))
+                map_path.write_text(json.dumps(mapping))
+                with mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", root), \
+                     mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                     mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                    with self.assertRaisesRegex(roster.RosterError, message):
+                        roster.validate_bundle(90)
+
+    def test_refresh_validates_paths_before_creating_data_directory(self):
+        with tempfile.TemporaryDirectory(prefix="community-refresh-path-") as 
directory:
+            root = pathlib.Path(directory)
+            outside = root / "outside"
+            outside.mkdir()
+            (root / "data").symlink_to(outside, target_is_directory=True)
+            data_dir = root / "data" / "community"
+            with mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", data_dir), \
+                 mock.patch.object(roster, "ROSTER_PATH", data_dir / 
"roster.json"), \
+                 mock.patch.object(roster, "MAP_PATH", data_dir / 
"github-map.json"), \
+                 mock.patch.object(roster, "AVATAR_DIR", root / "static" / 
"img" / "community" / "avatars"):
+                with self.assertRaisesRegex(roster.RosterError, "symlink path 
components"):
+                    roster.refresh()
+            self.assertFalse((outside / "community").exists())
+
+    def test_validator_rejects_same_name_out_of_asf_id_order(self):
+        committee, projects, people, mapping = self.fixture()
+        committee["committees"]["hugegraph"]["roster"]["alpha"] = {}
+        projects["projects"]["hugegraph"]["owners"].append("alpha")
+        projects["projects"]["hugegraph"]["members"].append("alpha")
+        people["people"]["zeta"]["name"] = "Same Name"
+        people["people"]["alpha"] = {"name": "Same Name"}
+        candidate = roster.build_roster(committee, projects, people, mapping)
+        candidate["roles"]["pmc"][1:] = reversed(candidate["roles"]["pmc"][1:])
+        with tempfile.TemporaryDirectory(prefix="community-order-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+            roster_path.write_text(json.dumps(candidate))
+            map_path.write_text(json.dumps(mapping))
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "ROOT", root), \
+                 mock.patch.object(roster, "DATA_DIR", root), \
+                 mock.patch.object(roster, "AVATAR_DIR", root / "avatars"):
+                with self.assertRaisesRegex(roster.RosterError, "sorted by 
public name"):
+                    roster.validate_bundle(90)
+
+    def test_fetch_failure_preserves_last_good(self):
+        original, old_fetch = roster.ROSTER_PATH.read_bytes(), 
roster._fetch_json
+        try:
+            roster._fetch_json = lambda _url: (_ for _ in 
()).throw(OSError("network down"))
+            with self.assertRaises(OSError):
+                roster.refresh()
+        finally:
+            roster._fetch_json = old_fetch
+        self.assertEqual(original, roster.ROSTER_PATH.read_bytes())
+
+    def test_refresh_duplicate_ldap_ids_preserves_last_good(self):
+        for field in ("owners", "members"):
+            with self.subTest(field=field), tempfile.TemporaryDirectory(
+                prefix="community-duplicate-test-"
+            ) as directory:
+                root = pathlib.Path(directory)
+                data_dir = root / "data"
+                roster_path, map_path = data_dir / "roster.json", data_dir / 
"github-map.json"
+                data_dir.mkdir()
+                roster_path.write_bytes(b"last-good\n")
+                committee, projects, people, mapping = self.fixture()
+                projects["projects"]["hugegraph"][field].append(
+                    projects["projects"]["hugegraph"][field][0]
+                )
+                sources = {
+                    roster.SOURCES["committee"]: committee,
+                    roster.SOURCES["projects"]: projects,
+                    roster.SOURCES["people"]: people,
+                }
+                map_path.write_text(json.dumps(mapping))
+                with mock.patch.object(roster, "ROOT", root), \
+                     mock.patch.object(roster, "DATA_DIR", data_dir), \
+                     mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                     mock.patch.object(roster, "MAP_PATH", map_path), \
+                     mock.patch.object(roster, "AVATAR_DIR", root / 
"avatars"), \
+                     mock.patch.object(roster, "_fetch_json", 
side_effect=sources.__getitem__), \
+                     mock.patch.object(roster, "_commit_bundle") as commit:
+                    with self.assertRaisesRegex(
+                        roster.RosterError,
+                        rf"LDAP project {field} contains duplicate ASF IDs",
+                    ):
+                        roster.refresh()
+                commit.assert_not_called()
+                self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+    def test_copy_failure_preserves_last_good_bundle(self):
+        with tempfile.TemporaryDirectory(prefix="community-copy-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, avatar_dir = root / "roster.json", root / "avatars"
+            avatar_dir.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            (avatar_dir / "old.webp").write_bytes(b"old")
+            candidate = {"roles": {"pmc": [{"avatar": 
"/img/community/avatars/new.webp"}], "committers": []}}
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
+                 mock.patch.object(roster, "_validate_avatar_blob"), \
+                 mock.patch.object(roster, "_copy_candidate", 
side_effect=OSError("copy failed")):
+                with self.assertRaisesRegex(OSError, "copy failed"):
+                    roster._commit_bundle(candidate, {"new.webp": b"new"})
+            self.assertEqual(b"last-good\n", roster_path.read_bytes())
+            self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+
+    def test_candidate_cleanup_failure_does_not_publish_roster(self):
+        with tempfile.TemporaryDirectory(prefix="community-cleanup-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, map_path = root / "roster.json", root / 
"github-map.json"
+            roster_path.write_bytes(b"last-good\n")
+            map_path.write_text('{"schema_version": 1, "mappings": {}}')
+            candidate = {"roles": {"pmc": [], "committers": []}}
+            with mock.patch.object(roster, "DATA_DIR", root), \
+                 mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "MAP_PATH", map_path), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
+                 mock.patch.object(roster, "_fetch_json", return_value={}), \
+                 mock.patch.object(roster, "build_roster", 
return_value=candidate), \
+                 mock.patch.object(roster, "_install_avatars"), \
+                 mock.patch.object(roster.shutil, "rmtree", 
side_effect=OSError("cleanup failed")):
+                with self.assertRaisesRegex(OSError, "cleanup failed"):
+                    roster.refresh()
+            self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+    def test_atomic_roster_write_failure_keeps_last_good_selected(self):
+        with tempfile.TemporaryDirectory(prefix="community-write-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, avatar_dir = root / "roster.json", root / "avatars"
+            avatar_dir.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            candidate = {"roles": {"pmc": [{"avatar": 
"/img/community/avatars/new.webp"}], "committers": []}}
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
+                 mock.patch.object(roster, "_validate_avatar_blob"), \
+                 mock.patch.object(roster, "_atomic_write", 
side_effect=OSError("write failed")):
+                with self.assertRaisesRegex(OSError, "write failed"):
+                    roster._commit_bundle(candidate, {"new.webp": b"new"})
+            self.assertEqual(b"last-good\n", roster_path.read_bytes())
+
+    def test_orphan_unlink_failure_is_a_successful_commit_warning(self):
+        with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, avatar_dir = root / "roster.json", root / "avatars"
+            avatar_dir.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            (avatar_dir / "old.webp").write_bytes(b"old")
+            candidate = {"roles": {"pmc": [{"avatar": 
"/img/community/avatars/new.webp"}], "committers": []}}
+            real_unlink, failed = roster._unlink, False
+
+            def fail_once(path):
+                nonlocal failed
+                if path.name == "old.webp" and not failed:
+                    failed = True
+                    raise OSError("unlink failed")
+                real_unlink(path)
+
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
+                 mock.patch.object(roster, "_validate_avatar_blob"), \
+                 mock.patch.object(roster, "_unlink", side_effect=fail_once):
+                roster._commit_bundle(candidate, {"new.webp": b"new"})
+            self.assertNotEqual(b"last-good\n", roster_path.read_bytes())
+            self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+            self.assertEqual(b"new", (avatar_dir / "new.webp").read_bytes())
+
+    def test_corrupt_existing_candidate_destination_is_replaced(self):
+        with tempfile.TemporaryDirectory(prefix="community-replace-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, avatar_dir = root / "roster.json", root / "avatars"
+            avatar_dir.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            raw = b"new"
+            name = f"{__import__('hashlib').sha256(raw).hexdigest()}.webp"
+            destination = avatar_dir / name
+            destination.write_bytes(b"corrupt")
+            candidate = {"roles": {"pmc": [{"avatar": 
f"/img/community/avatars/{name}"}], "committers": []}}
+            with mock.patch.object(roster, "ROSTER_PATH", roster_path), \
+                 mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \
+                 mock.patch.object(roster, "_validate_repo_paths"), \
+                 mock.patch.object(roster, "_validate_webp"):
+                roster._commit_bundle(candidate, {name: raw})
+            self.assertEqual(raw, destination.read_bytes())
+
+
+class CommunityContentContractTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls._site = 
tempfile.TemporaryDirectory(prefix="community-content-site-")
+        hugo_version = subprocess.check_output(["hugo", "version"], text=True)
+        if not hugo_version.startswith("hugo v0.165.0") or "+extended" not in 
hugo_version:
+            raise RuntimeError(
+                f"Community render contracts require Hugo v0.165.0 Extended: 
{hugo_version.strip()}"
+            )
+        environment = {**os.environ, "GOPROXY": "off"}
+        subprocess.run(
+            ["hugo", "--quiet", "--destination", cls._site.name],
+            cwd=ROOT,
+            env=environment,
+            text=True,
+            capture_output=True,
+            check=True,

Review Comment:
   Addressed in d25c82d75: the trusted prepare job now runs go mod download 
before the GOPROXY=off Python/Hugo contract suite; actionlint and the workflow 
contract pass.



##########
i18n/en.yaml:
##########
@@ -4,3 +4,24 @@ ui_ask_ai_description: Powered by Kapa; only your question is 
sent.
 ui_ask_ai_latest: Answers use the latest documentation
 ui_retry: Retry
 ui_ai_error: AI is temporarily unavailable. Local search is unaffected.
+ui_more_versions: More versions
+download_release_version: Version
+download_release_date: Release Date
+download_release_notes: Release Notes
+download_table_component: Component
+download_table_type: Type
+download_table_mirror: Download (ASF mirror)
+download_type_binary: Binary
+download_type_source: Source
+download_component_server: Server
+download_component_toolchain: Toolchain
+download_component_ai: AI
+download_component_computer: Computer
+download_component_commons: Common
+download_asf_note: All packages on this page are official Apache Software 
Foundation releases served from ASF mirrors, with signatures and checksums 
hosted on downloads.apache.org. Source archives generated automatically by 
GitHub are not ASF releases.

Review Comment:
   Addressed in d25c82d75: EN and CN now identify source archives as official 
ASF releases and binary packages as convenience builds, while retaining ASF 
mirror, signature, and checksum wording.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to