This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch feat/oink-community-content
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit c7c1d84ef0ac360058ae75adc33df4bca71690c4
Author: dark <[email protected]>
AuthorDate: Fri Sep 4 19:41:09 2026 +0800

    fix(community): make roster refresh recoverable
    
    - validate exact ASF phonebook links for unmapped members\n- stabilize 
same-name ordering with an ASF ID tie-breaker\n- restore the prior roster and 
avatars after cleanup failure\n- assert built indexes, content outputs, and 
LLMSFULL artifacts
---
 scripts/community_roster.py      |  99 ++++++++++++++++----
 scripts/test_community_roster.py | 190 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 272 insertions(+), 17 deletions(-)

diff --git a/scripts/community_roster.py b/scripts/community_roster.py
index 8c8e61a91..78b82a4cb 100644
--- a/scripts/community_roster.py
+++ b/scripts/community_roster.py
@@ -65,6 +65,10 @@ def _initials(name: str) -> str:
     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")
@@ -199,8 +203,8 @@ def build_roster(committee_data: dict, projects_data: dict, 
people_data: dict, m
         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}
-    pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: 
names[item].casefold())
-    committer_ids = sorted(member_ids - owner_ids, key=lambda item: 
names[item].casefold())
+    pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: 
_sort_key(item, names))
+    committer_ids = sorted(member_ids - owner_ids, key=lambda item: 
_sort_key(item, names))
     return {
         "schema_version": SCHEMA_VERSION,
         "project": PROJECT,
@@ -269,7 +273,8 @@ def validate_bundle(warn_after_days: int) -> list[str]:
         raise RosterError("roster.json: unique Chair must be first in PMC")
     for role, entries in roles.items():
         tail = entries[1:] if role == "pmc" else entries
-        if [p.get("name", "").casefold() for p in tail] != 
sorted(p.get("name", "").casefold() for p in tail):
+        actual_order = [(p.get("name", "").casefold(), p.get("asf_id", 
"").casefold()) for p in tail]
+        if actual_order != sorted(actual_order):
             raise RosterError(f"roster.json: {role} must be sorted by public 
name casefold")
         for person in entries:
             if any(key not in person for key in ("asf_id", "name", "initials", 
"chair", "profile_url")):
@@ -294,6 +299,8 @@ def validate_bundle(warn_after_days: int) -> list[str]:
                 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}")
     try:
         retrieved = 
dt.datetime.fromisoformat(roster["retrieved_at"].replace("Z", "+00:00"))
     except (KeyError, TypeError, ValueError) as exc:
@@ -338,25 +345,83 @@ def validate_rendered_outputs() -> None:
                 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 _commit_bundle(candidate: dict, candidate_avatars: pathlib.Path) -> None:
+    """Install one bundle or restore the exact prior roster/avatar state."""
+    old_roster = ROSTER_PATH.read_bytes() if ROSTER_PATH.exists() else None
+    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")}
+    orphan_bytes = {name: path.read_bytes() for name, path in existing.items() 
if name not in referenced}
+    installed: list[pathlib.Path] = []
+    roster_replaced = False
+    try:
+        for avatar in sorted(candidate_avatars.glob("*.webp")):
+            destination = AVATAR_DIR / avatar.name
+            if destination.exists():
+                continue
+            staged = AVATAR_DIR / f".{avatar.name}.candidate"
+            try:
+                shutil.copyfile(avatar, staged)
+                os.replace(staged, destination)
+            finally:
+                if staged.exists():
+                    _unlink(staged)
+            installed.append(destination)
+        raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + 
"\n").encode()
+        _atomic_write(ROSTER_PATH, raw)
+        roster_replaced = True
+        for name in sorted(orphan_bytes):
+            _unlink(AVATAR_DIR / name)
+    except Exception:
+        if roster_replaced:
+            if old_roster is None:
+                try:
+                    _unlink(ROSTER_PATH)
+                except OSError:
+                    pass
+            else:
+                _atomic_write(ROSTER_PATH, old_roster)
+        for name, raw in orphan_bytes.items():
+            _atomic_write(AVATAR_DIR / name, raw)
+        for path in installed:
+            try:
+                _unlink(path)
+            except OSError:
+                pass
+        raise
+
+
 def refresh() -> None:
     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))
     with tempfile.TemporaryDirectory(prefix=".community-refresh-", 
dir=DATA_DIR) as work:
-        work_path = pathlib.Path(work)
-        candidate_avatars = work_path / "avatars"
+        candidate_avatars = pathlib.Path(work) / "avatars"
         _install_avatars(candidate, candidate_avatars)
-        candidate_path = work_path / "roster.json"
-        candidate_path.write_text(json.dumps(candidate, indent=2, 
ensure_ascii=False) + "\n", encoding="utf-8")
-        AVATAR_DIR.mkdir(parents=True, exist_ok=True)
-        for avatar in candidate_avatars.glob("*.webp"):
-            destination = AVATAR_DIR / avatar.name
-            if not destination.exists():
-                shutil.copyfile(avatar, destination)
-        os.replace(candidate_path, ROSTER_PATH)
-        referenced = {pathlib.PurePosixPath(p["avatar"]).name for role in 
candidate["roles"].values() for p in role if p.get("avatar")}
-        for avatar in AVATAR_DIR.glob("*.webp"):
-            if avatar.name not in referenced:
-                avatar.unlink()
+        _commit_bundle(candidate, candidate_avatars)
 
 
 def main() -> int:
diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py
index e9b64de2a..4e2f4fa7f 100644
--- a/scripts/test_community_roster.py
+++ b/scripts/test_community_roster.py
@@ -1,6 +1,12 @@
 import importlib.util
+import json
+import os
 import pathlib
+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")
@@ -24,6 +30,25 @@ class CommunityRosterTests(unittest.TestCase):
         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")
@@ -47,6 +72,40 @@ class CommunityRosterTests(unittest.TestCase):
     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, "AVATAR_DIR", root / "avatars"):
+                with self.assertRaisesRegex(roster.RosterError, "unmapped 
profile URL mismatch"):
+                    roster.validate_bundle(90)
+
+    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, "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:
@@ -57,8 +116,72 @@ class CommunityRosterTests(unittest.TestCase):
             roster._fetch_json = old_fetch
         self.assertEqual(original, roster.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, candidates = root / "roster.json", root / 
"avatars", root / "candidates"
+            avatar_dir.mkdir()
+            candidates.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            (avatar_dir / "old.webp").write_bytes(b"old")
+            (candidates / "new.webp").write_bytes(b"new")
+            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.shutil, "copyfile", 
side_effect=OSError("copy failed")):
+                with self.assertRaisesRegex(OSError, "copy failed"):
+                    roster._commit_bundle(candidate, candidates)
+            self.assertEqual(b"last-good\n", roster_path.read_bytes())
+            self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+
+    def test_unlink_failure_rolls_back_roster_and_avatars(self):
+        with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as 
directory:
+            root = pathlib.Path(directory)
+            roster_path, avatar_dir, candidates = root / "roster.json", root / 
"avatars", root / "candidates"
+            avatar_dir.mkdir()
+            candidates.mkdir()
+            roster_path.write_bytes(b"last-good\n")
+            (avatar_dir / "old.webp").write_bytes(b"old")
+            (candidates / "new.webp").write_bytes(b"new")
+            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, "_unlink", side_effect=fail_once):
+                with self.assertRaisesRegex(OSError, "unlink failed"):
+                    roster._commit_bundle(candidate, candidates)
+            self.assertEqual(b"last-good\n", roster_path.read_bytes())
+            self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes())
+            self.assertFalse((avatar_dir / "new.webp").exists())
+
 
 class CommunityContentContractTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls._site = 
tempfile.TemporaryDirectory(prefix="community-content-site-")
+        environment = {**os.environ, "GOPROXY": "off"}
+        subprocess.run(
+            ["hugo", "--quiet", "--destination", cls._site.name],
+            cwd=ROOT,
+            env=environment,
+            text=True,
+            capture_output=True,
+            check=True,
+        )
+        cls.site = pathlib.Path(cls._site.name)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls._site.cleanup()
+
     def test_search_metadata_covers_fixed_bilingual_entries(self):
         entries = [
             "docs/introduction/_index.md",
@@ -95,6 +218,73 @@ class CommunityContentContractTests(unittest.TestCase):
             self.assertIn(".full-width", vertex)
             self.assertIn("{#vertex-id-strategy", vertex)
 
+    def test_component_pilots_render_in_html_print_and_markdown(self):
+        for prefix in ("", "cn/"):
+            outputs = {
+                "server_html": self.site / prefix / 
"docs/quickstart/hugegraph/hugegraph-server/index.html",
+                "server_print": self.site / prefix / 
"_print/docs/quickstart/hugegraph/index.html",
+                "server_md": self.site / prefix / 
"docs/quickstart/hugegraph/hugegraph-server/index.md",
+                "config_html": self.site / prefix / 
"docs/config/config-guide/index.html",
+                "config_print": self.site / prefix / 
"_print/docs/config/index.html",
+                "config_md": self.site / prefix / 
"docs/config/config-guide/index.md",
+                "vertex_html": self.site / prefix / 
"docs/clients/restful-api/vertex/index.html",
+                "vertex_print": self.site / prefix / 
"_print/docs/clients/restful-api/index.html",
+                "vertex_md": self.site / prefix / 
"docs/clients/restful-api/vertex/index.md",
+            }
+            rendered = {key: path.read_text(encoding="utf-8") for key, path in 
outputs.items()}
+            self.assertIn('class="steps"', rendered["server_html"])
+            self.assertIn('class="steps"', rendered["server_print"])
+            self.assertIn("{.steps}", rendered["server_md"])
+            for key in ("config_html", "config_print", "config_md"):
+                self.assertIn("conf/gremlin-server.yaml", rendered[key])
+            self.assertIn('id="vertex-id-strategy"', rendered["vertex_html"])
+            self.assertIn('id="vertex-id-strategy"', rendered["vertex_print"])
+            self.assertIn("{#vertex-id-strategy .full-width", 
rendered["vertex_md"])
+
+    def test_fixed_metadata_is_present_in_actual_offline_indexes(self):
+        relative_refs = [
+            "docs/introduction/",
+            "docs/quickstart/hugegraph/hugegraph-server/",
+            "docs/quickstart/hugegraph/hugegraph-hstore/",
+            "docs/quickstart/hugegraph/hugegraph-pd/",
+            "docs/quickstart/computing/hugegraph-computer/",
+            "docs/quickstart/toolchain/hugegraph-loader/",
+            "docs/quickstart/toolchain/hugegraph-hubble/",
+            "docs/clients/",
+            "docs/clients/restful-api/",
+            "docs/config/config-guide/",
+            "docs/config/config-authentication/",
+            "docs/download/download/",
+        ]
+        for language, prefix in (("en", "/"), ("cn", "/cn/")):
+            indexes = 
list(self.site.glob(f"offline-search-index.{language}.*.json"))
+            self.assertEqual(1, len(indexes))
+            records = {record["ref"]: record for record in 
json.loads(indexes[0].read_text())}
+            for relative in relative_refs:
+                ref = prefix + relative
+                self.assertIn(ref, records)
+                self.assertTrue(records[ref]["keywords"], ref)
+                self.assertGreater(records[ref]["boost"], 1, ref)
+
+    def test_llmsfull_outputs_exist_and_include_expected_documents(self):
+        outputs = {
+            self.site / "docs/llms-full.txt": (
+                "# HugeGraph Server Quick Start",
+                "# Server Startup Guide",
+                "# Vertex API",
+            ),
+            self.site / "cn/docs/llms-full.txt": (
+                "# HugeGraph Server 快速开始",
+                "# Server 启动指南",
+                "# Vertex API",
+            ),
+        }
+        for path, markers in outputs.items():
+            self.assertTrue(path.is_file())
+            rendered = path.read_text(encoding="utf-8")
+            for marker in markers:
+                self.assertIn(marker, rendered)
+
 
 if __name__ == "__main__":
     unittest.main()

Reply via email to