diff --git a/.github/workflows/wall-of-honour.yml b/.github/workflows/wall-of-honour.yml
index 43668e0..4e5a8d1 100644
--- a/.github/workflows/wall-of-honour.yml
+++ b/.github/workflows/wall-of-honour.yml
@@ -29,10 +29,10 @@ jobs:
GITHUB_ORG: BHoM
- name: Commit if changed
run: |
- if [[ -n "$(git status --porcelain profile/README.md)" ]]; then
+ if [[ -n "$(git status --porcelain profile/README.md profile/wall_roster.json)" ]]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add profile/README.md
+ git add profile/README.md profile/wall_roster.json
git commit -m "chore(wall): refresh wall of honour"
git push
else
diff --git a/profile/README.md b/profile/README.md
index 445859f..48040a9 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -1,526 +1,531 @@
-## Welcome to the BHoM!
-
-The BHoM (Buildings and Habitats object Model) is a collaborative project for the AEC (Architecture, Engineering and Construction) industry that aims to improve how we design the Built Environment and human Habitats.
-
-BHoM is a collection of schemas, functionality and interoperability infrastructure, developed with the aim of improving the way we work and communicate in AEC. BHoM is crafted as transdisciplinary, software-agnostic and office/region/country-invariant. BHoM is powered by an active, diverse community and a wide range of contributors.
-The whole BHoM project uses an open-source model for project architecture, co-creation and planning. Please explore, experiment and contribute to both the source code and the documentation! Open-source means we can feed off and pool our disparate knowledge, experience and expertise towards a common goal â better design and a better Built Environment and Habitats for everyone.
-
-#### Technical philosophy of the BHoM
-
-The BHoM integrates concepts from different languages, coding environments and platforms. It's designed to be compatible with both visual flow-based programming (e.g. [Grasshopper](https://simplyrhino.co.uk/3d-modelling-software/grasshopper), Excel) and programming (in particular, C#). This is to integrate well in existing workflows of professionals in the AEC industry, regardless of their level of computational proficiency. BHoM aims to combine the work of professional programmers with the valuable efforts of computational designers/engineers/architects and scripters, all in the same ecosystem.
-
-### Contributing
-
-Please see https://bhom.xyz/documentation/Contributing/! đ
-
-### Documentation and Q&A
-
-All documentation can be found in https://bhom.xyz/documentation/!
-We also have a space for questions and discussions: https://github.com/orgs/BHoM/discussions.
-
-Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions).
-
-
-## Our Contributors
-
-
-
-
-
-_Last updated: 2026-07-01_
-
+## Welcome to the BHoM!
+
+The BHoM (Buildings and Habitats object Model) is a collaborative project for the AEC (Architecture, Engineering and Construction) industry that aims to improve how we design the Built Environment and human Habitats.
+
+BHoM is a collection of schemas, functionality and interoperability infrastructure, developed with the aim of improving the way we work and communicate in AEC. BHoM is crafted as transdisciplinary, software-agnostic and office/region/country-invariant. BHoM is powered by an active, diverse community and a wide range of contributors.
+The whole BHoM project uses an open-source model for project architecture, co-creation and planning. Please explore, experiment and contribute to both the source code and the documentation! Open-source means we can feed off and pool our disparate knowledge, experience and expertise towards a common goal â better design and a better Built Environment and Habitats for everyone.
+
+#### Technical philosophy of the BHoM
+
+The BHoM integrates concepts from different languages, coding environments and platforms. It's designed to be compatible with both visual flow-based programming (e.g. [Grasshopper](https://simplyrhino.co.uk/3d-modelling-software/grasshopper), Excel) and programming (in particular, C#). This is to integrate well in existing workflows of professionals in the AEC industry, regardless of their level of computational proficiency. BHoM aims to combine the work of professional programmers with the valuable efforts of computational designers/engineers/architects and scripters, all in the same ecosystem.
+
+### Contributing
+
+Please see https://bhom.xyz/documentation/Contributing/! đ
+
+### Documentation and Q&A
+
+All documentation can be found in https://bhom.xyz/documentation/!
+We also have a space for questions and discussions: https://github.com/orgs/BHoM/discussions.
+
+Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions).
+
+
+Our Contributors
+
+
+
+Thank you to everyone who has contributed to the BHoM.
+
+
+
+_Last updated: 2026-07-01_
+
diff --git a/profile/wall_roster.json b/profile/wall_roster.json
new file mode 100644
index 0000000..63c47cb
--- /dev/null
+++ b/profile/wall_roster.json
@@ -0,0 +1,455 @@
+{
+ "AhmadSater": {
+ "name": "Ahmad Sater"
+ },
+ "AhmedElnagar1": {
+ "name": "Ahmed Elnagar"
+ },
+ "AlaaAlfakara": {
+ "name": "Alaa Alfakara"
+ },
+ "Arne-Martensen": {
+ "name": "Arne Martensen"
+ },
+ "BingWangUS": {
+ "name": "Bing Wang"
+ },
+ "CKBoulter": {
+ "name": "Courtney Boulter"
+ },
+ "ChrisSelf2": {
+ "name": "Chris Self"
+ },
+ "Chrisshort92": {
+ "name": "Christopher Short"
+ },
+ "Clemens-Preisinger": {
+ "name": "Clemens Preisinger"
+ },
+ "Conor-Hubert-BH": {
+ "name": "Conor Hubert"
+ },
+ "DanielJonssonBH": {
+ "name": "Daniel Jonsson"
+ },
+ "DarienC": {
+ "name": "DarienC"
+ },
+ "DiellzaElshani": {
+ "name": "DiellzaElshani"
+ },
+ "Dmamedova": {
+ "name": "Djamila Mamedova"
+ },
+ "EKAdebo": {
+ "name": "Ellie Adebowale"
+ },
+ "EddyDalton": {
+ "name": "Eddy Dalton"
+ },
+ "EmmaSander": {
+ "name": "Emma Sander"
+ },
+ "EnricoAntolini": {
+ "name": "EnricoAntolini"
+ },
+ "Erida-Bendo": {
+ "name": "Erida Bendo"
+ },
+ "Felix-Mallinder": {
+ "name": "Felix Mallinder"
+ },
+ "FraserGreenroyd": {
+ "name": "Fraser Greenroyd"
+ },
+ "FredericBourgeon": {
+ "name": "Frédéric Bourgeon"
+ },
+ "GCRA101": {
+ "name": "Giorgio Carlo Roberto Albieri"
+ },
+ "IsakLarbornBH": {
+ "name": "Isak Larborn"
+ },
+ "IsakNaslundBh": {
+ "name": "Isak NĂ€slund"
+ },
+ "JohannaOlin1": {
+ "name": "Johanna Olin"
+ },
+ "JohnSloane": {
+ "name": "John Sloane"
+ },
+ "JonathanNillius": {
+ "name": "Jonathan Nillius"
+ },
+ "JoseAguilarBH": {
+ "name": "Jose Castro Aguilar"
+ },
+ "KalleEdstroem": {
+ "name": "Kalle Edstroem"
+ },
+ "Kar-Myl": {
+ "name": "Kartheek Mylavarapu"
+ },
+ "Kenton-Phillips": {
+ "name": "Kenton Phillips"
+ },
+ "KentonPhillips-BH": {
+ "name": "Kenton Phillips"
+ },
+ "KonradStolarski": {
+ "name": "Konrad Stolarski"
+ },
+ "LMarkowski": {
+ "name": "Ćukasz Markowski"
+ },
+ "LivAndersson": {
+ "name": "Liv Andersson"
+ },
+ "MILeach": {
+ "name": "MILeach"
+ },
+ "MajaLindroth": {
+ "name": "Maja Lindroth"
+ },
+ "Maria1129": {
+ "name": "Maria Roman"
+ },
+ "MariaGlimaker": {
+ "name": "Maria Glimaker"
+ },
+ "Martian42": {
+ "name": "Marvin Suen"
+ },
+ "MartinHenriksen": {
+ "name": "Martin B. Henriksen"
+ },
+ "MatildaSvenssonBH": {
+ "name": "Matilda Svensson"
+ },
+ "MayaAroraJonsson": {
+ "name": "Maya Arora-Jonsson"
+ },
+ "Nazeefa710": {
+ "name": "Nazeefa Bushra"
+ },
+ "Oceania2018": {
+ "name": "Haiping"
+ },
+ "PaulPoinet": {
+ "name": "Paul Poinet"
+ },
+ "PiotrBuda": {
+ "name": "PiotrBuda"
+ },
+ "RasmusCanfjorden": {
+ "name": "Rasmus Canfjorden"
+ },
+ "RichardWhitfield": {
+ "name": "Richard Whitfield"
+ },
+ "Robadob": {
+ "name": "Robert Chisholm"
+ },
+ "SallyPickard": {
+ "name": "Sally Pickard"
+ },
+ "StephennipBH": {
+ "name": "StephennipBH"
+ },
+ "Tom-Kingstone": {
+ "name": "Thomas Edward Kingstone"
+ },
+ "TosteSkDa": {
+ "name": "Toste Skanberg Dahlstedt"
+ },
+ "Tyriar": {
+ "name": "Daniel Imms"
+ },
+ "WojciechBuda00": {
+ "name": "wojciech buda"
+ },
+ "adam-sobieski": {
+ "name": "Adam Sobieski"
+ },
+ "adecler": {
+ "name": "Arnaud Declercq"
+ },
+ "agneslindblom": {
+ "name": "Agnes Lindblom"
+ },
+ "ajensen19": {
+ "name": "Aurora Jensen"
+ },
+ "akaraiskou": {
+ "name": "Antigoni Karaiskou"
+ },
+ "al-fisher": {
+ "name": "Al Fisher"
+ },
+ "albinber": {
+ "name": "Albin Bergh"
+ },
+ "alelom": {
+ "name": "Alessio Lombardi"
+ },
+ "alexissantella": {
+ "name": "Alexis Santella"
+ },
+ "alexradne": {
+ "name": "Alex Radne"
+ },
+ "amorehouse-bh": {
+ "name": "Anthony Morehouse"
+ },
+ "andrbak": {
+ "name": "Andreas Bak"
+ },
+ "andrewengh": {
+ "name": "Andrew Engh"
+ },
+ "annastigenberg": {
+ "name": "Anna Stigenberg"
+ },
+ "arvidsigurdsson": {
+ "name": "Arvid Sigurdsson"
+ },
+ "carmenfan": {
+ "name": "Carmen Fan"
+ },
+ "charliebrookerBH": {
+ "name": "Charlie Brooker"
+ },
+ "deepakkumar1984": {
+ "name": "Deesha Tech"
+ },
+ "desaiwangBH": {
+ "name": "Desai Wang"
+ },
+ "didimitrie": {
+ "name": "Dimitrie Stefanescu"
+ },
+ "dmitryzelenkin": {
+ "name": "Dmitry Zelenkin"
+ },
+ "eUsmanovaBH": {
+ "name": "Elina Usmanova"
+ },
+ "edforsberg": {
+ "name": "Erik Forsberg"
+ },
+ "emidio-piermarini": {
+ "name": "JS"
+ },
+ "emilsvedjer": {
+ "name": "Emil Svedjer"
+ },
+ "enarhi": {
+ "name": "Erik Narhi"
+ },
+ "epersson": {
+ "name": "Einar Persson"
+ },
+ "epignatelli": {
+ "name": "Eduardo Pignatelli"
+ },
+ "evanPantazis": {
+ "name": "Evangelos Pantazis"
+ },
+ "felipebandeiramm": {
+ "name": "felipebandeiramm"
+ },
+ "fraguada": {
+ "name": "Luis E. Fraguada"
+ },
+ "geirsoderin": {
+ "name": "Geir Soderin"
+ },
+ "gideontamir16": {
+ "name": "Gideon Tamir"
+ },
+ "harmanpa": {
+ "name": "Peter Harman"
+ },
+ "hedvigkjellander": {
+ "name": "Hedvig Kjellander"
+ },
+ "henon": {
+ "name": "Meinrad Recheis"
+ },
+ "hmoubarak": {
+ "name": "Habiba Moubarak"
+ },
+ "izzylys": {
+ "name": "izzy lys"
+ },
+ "jacosoft": {
+ "name": "Jaco Bekker"
+ },
+ "jamesramsden-bh": {
+ "name": "James Ramsden"
+ },
+ "johannaisak": {
+ "name": "Johanna Isaksson"
+ },
+ "jtrainor1": {
+ "name": "Jack Trainor"
+ },
+ "jukkasus": {
+ "name": "jukkasus"
+ },
+ "junjieim": {
+ "name": "Junjie"
+ },
+ "kThorsager": {
+ "name": "Kalle Thorsager"
+ },
+ "kaichelliah": {
+ "name": "Kai Chelliah"
+ },
+ "kasiawod": {
+ "name": "Kasia Wodzisz"
+ },
+ "kayleighhoude": {
+ "name": "Kayleigh Houde"
+ },
+ "kprusicka": {
+ "name": "Karolina Prusicka"
+ },
+ "lborgenstam": {
+ "name": "Ludvig Borgenstam"
+ },
+ "linaeriksson": {
+ "name": "Lina Eriksson"
+ },
+ "linhnam-nguyen": {
+ "name": "linhnam-nguyen"
+ },
+ "livand": {
+ "name": "livand"
+ },
+ "m-clare": {
+ "name": "Maryanne Wachter"
+ },
+ "mariatjader": {
+ "name": "Maria Tjader"
+ },
+ "maryannewachter": {
+ "name": "Maryanne Wachter"
+ },
+ "mchaf": {
+ "name": "Manuel Chafart"
+ },
+ "mcunliffe84": {
+ "name": "Mark Cunliffe"
+ },
+ "michaelhoehn": {
+ "name": "Michael Hoehn"
+ },
+ "michal-pekacki": {
+ "name": "Michal Pekacki"
+ },
+ "michaldengusiak": {
+ "name": "Michal Dengusiak"
+ },
+ "milogillot-BH": {
+ "name": "Milo Gillot"
+ },
+ "mishaelnuh": {
+ "name": "Mish Nuh"
+ },
+ "mondus": {
+ "name": "Paul Richmond"
+ },
+ "mozhgan-kch": {
+ "name": "Mozhgan Kabiri Chimeh"
+ },
+ "msandmer": {
+ "name": "Madelene Sandmer"
+ },
+ "mswaidan": {
+ "name": "Matthew Swaidan"
+ },
+ "ncbrown2": {
+ "name": "Nathan Brown"
+ },
+ "njsmithies": {
+ "name": "Nicola Smithies"
+ },
+ "non-manifold": {
+ "name": "non-manifold"
+ },
+ "ofekbruhis": {
+ "name": "Ofek Bruhis"
+ },
+ "oscarborgstrom": {
+ "name": "Oscar Borgström"
+ },
+ "pawelbaran": {
+ "name": "Pawel Baran"
+ },
+ "pearswj": {
+ "name": "Will Pearson"
+ },
+ "peterjamesnugent": {
+ "name": "Peter Nugent"
+ },
+ "polnischfrosch": {
+ "name": "AaronWagner"
+ },
+ "ptheywood": {
+ "name": "Peter Heywood"
+ },
+ "puriasafarihesari": {
+ "name": "Puria Safari Hesari"
+ },
+ "rboulton-BH": {
+ "name": "Ross Boulton"
+ },
+ "reneschulte": {
+ "name": "Rene Schulte"
+ },
+ "robinflyman": {
+ "name": "Robin Flyman"
+ },
+ "rolyhudson": {
+ "name": "Roly Hudson"
+ },
+ "rwemay": {
+ "name": "Rob May"
+ },
+ "samuelbasimalla": {
+ "name": "Samuel Basimalla"
+ },
+ "sofmal": {
+ "name": "Sofia Malmsten"
+ },
+ "staintono": {
+ "name": "Oliver Stainton"
+ },
+ "svaz00": {
+ "name": "Sam Vazquez"
+ },
+ "teocomi": {
+ "name": "Matteo Cominetti"
+ },
+ "tg359": {
+ "name": "Tristan Gerrish"
+ },
+ "theThorsager": {
+ "name": "Kalle Thorsager"
+ },
+ "tiagogrossi": {
+ "name": "Tiago Del Grossi"
+ },
+ "tsvilans": {
+ "name": "Tom Svilans"
+ },
+ "vgreen-BH": {
+ "name": "Valerie Green"
+ },
+ "vietle-bh": {
+ "name": "Viet Le"
+ }
+}
diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py
index 703f2f7..157db66 100644
--- a/scripts/generate_wall_of_honour.py
+++ b/scripts/generate_wall_of_honour.py
@@ -1,6 +1,7 @@
"""Generate BHoM Wall of Honour and splice into profile/README.md."""
from __future__ import annotations
+import json
import os
import re
import sys
@@ -15,10 +16,13 @@
def list_org_repos(session: requests.Session, org: str) -> list[dict[str, Any]]:
- """List non-archived repos in `org`, excluding the org's `.github` repo."""
+ """List repos in `org`, excluding the org's `.github` repo.
+
+ Archived repos are included so their contributors stay on the wall.
+ """
url = f"{GITHUB_API}/orgs/{org}/repos"
all_repos = paginated_get(session, url, params={"per_page": 100, "type": "all"})
- return [r for r in all_repos if not r["archived"] and r["name"] != ".github"]
+ return [r for r in all_repos if r["name"] != ".github"]
def fetch_repo_contributors(session: requests.Session, org: str, repo: str) -> list[dict[str, Any]]:
@@ -86,7 +90,11 @@ def enrich_display_names(
def _contributors_badge(count: int) -> str:
color = "lightgrey" if count == 0 else "brightgreen"
- return f""
+ # HTML img rather than markdown ![]() so it renders inside the wrapper
+ return (
+ f"
"
+ )
def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> str:
@@ -96,8 +104,8 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s
if not contributors:
return (
"\n"
- "## Our Contributors\n\n"
- f"{badge}\n\n"
+ "
Our Contributors
\n\n"
+ f"{badge}
\n\n"
"Wall coming soon. No contributors yet.\n\n"
f"_Last updated: {last_updated}_\n"
""
@@ -116,8 +124,9 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s
return (
"\n"
- "## Our Contributors\n\n"
- f"{badge}\n\n"
+ "Our Contributors
\n\n"
+ f"{badge}
\n\n"
+ "Thank you to everyone who has contributed to the BHoM.
\n\n"
"\n"
+ "\n".join(rows)
+ "\n
\n\n"
@@ -128,16 +137,57 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s
def _render_cell(login: str, info: dict[str, Any]) -> str:
name = info["name"]
+ cell_width = f"{100 / GRID_COLS:.2f}%"
+ # Request the avatar at 2x display size so it stays sharp on hi-DPI screens
return (
- " \n"
- f" "
- f" "
+ f" | \n"
+ f" "
+ f" "
f"{name}\n"
" | "
)
+DEFAULT_ROSTER_PATH = "profile/wall_roster.json"
+
+
+def load_roster(roster_path: str) -> dict[str, dict[str, Any]]:
+ """Load the persisted roster; missing file means an empty roster."""
+ path = Path(roster_path)
+ if not path.exists():
+ return {}
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def save_roster(roster_path: str, roster: dict[str, dict[str, Any]]) -> None:
+ """Write the roster as stable, diff-friendly JSON."""
+ path = Path(roster_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(roster, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+
+
+def merge_into_roster(
+ roster: dict[str, dict[str, Any]],
+ live: dict[str, dict[str, Any]],
+) -> dict[str, dict[str, Any]]:
+ """Union live contributors into the roster. Live data wins; absent logins persist.
+
+ Denylisted logins are dropped even if a past run added them.
+ """
+ merged = {
+ login: {"name": info["name"]}
+ for login, info in roster.items()
+ if login not in DENYLISTED_LOGINS
+ }
+ for login, info in live.items():
+ merged[login] = {"name": info["name"]}
+ return merged
+
+
WALL_MARKER_START = ""
WALL_MARKER_END = ""
_WALL_BLOCK_RE = re.compile(
@@ -184,6 +234,7 @@ def main() -> int:
token = os.environ["GITHUB_TOKEN"]
org = os.environ.get("GITHUB_ORG", "BHoM")
readme_path = os.environ.get("README_PATH", DEFAULT_README_PATH)
+ roster_path = os.environ.get("ROSTER_PATH", DEFAULT_ROSTER_PATH)
session = make_session(token)
@@ -202,8 +253,14 @@ def main() -> int:
print(f"Aggregated to {len(aggregated)} unique contributors. Enriching display names...")
enriched = enrich_display_names(session, aggregated)
+
+ roster = load_roster(roster_path)
+ merged = merge_into_roster(roster, enriched)
+ save_roster(roster_path, merged)
+ print(f"Roster: {len(roster)} known, {len(merged)} after merge.")
+
today = date.today().isoformat()
- wall_md = render_wall(enriched, today)
+ wall_md = render_wall(merged, today)
changed = splice_into_readme(readme_path, wall_md)
if changed:
diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py
index 608cbf6..1ec97cc 100644
--- a/scripts/tests/test_generate.py
+++ b/scripts/tests/test_generate.py
@@ -7,7 +7,8 @@
@responses.activate
-def test_list_org_repos_filters_archived_and_dot_github():
+def test_list_org_repos_includes_archived_excludes_dot_github():
+ # Archived repos are kept so their contributors stay on the wall.
responses.add(
responses.GET,
"https://api.github.com/orgs/BHoM/repos",
@@ -22,7 +23,7 @@ def test_list_org_repos_filters_archived_and_dot_github():
session = make_session("fake-token")
result = list_org_repos(session, "BHoM")
names = [r["name"] for r in result]
- assert names == ["BHoM", "BHoM_Engine"]
+ assert names == ["BHoM", "old_repo", "BHoM_Engine"]
@responses.activate
@@ -229,6 +230,42 @@ def test_render_wall_sort_is_case_insensitive_and_unicode():
assert names_in_order == sorted(names_in_order)
+from scripts.generate_wall_of_honour import load_roster, merge_into_roster, save_roster
+
+
+def test_load_roster_missing_file_returns_empty():
+ with tempfile.TemporaryDirectory() as tmp:
+ assert load_roster(str(Path(tmp) / "wall_roster.json")) == {}
+
+
+def test_roster_save_load_roundtrip():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = str(Path(tmp) / "wall_roster.json")
+ roster = {"alice": {"name": "Alice Example"}, "Ă„ngström": {"name": "Ă
ngström"}}
+ save_roster(path, roster)
+ assert load_roster(path) == roster
+
+
+def test_merge_preserves_absent_logins_and_refreshes_live():
+ roster = {
+ "gone": {"name": "Deleted Account"},
+ "alice": {"name": "Old Name"},
+ }
+ live = {"alice": {"name": "Alice Example", "avatar_url": "https://x/a", "contributions": 10}}
+ merged = merge_into_roster(roster, live)
+ assert merged == {
+ "gone": {"name": "Deleted Account"},
+ "alice": {"name": "Alice Example"},
+ }
+
+
+def test_merge_drops_denylisted_roster_entries():
+ # A login denylisted after a past run added it must not linger in the roster.
+ roster = {"BHoMBot": {"name": "BHoMBot"}, "alice": {"name": "Alice Example"}}
+ merged = merge_into_roster(roster, {})
+ assert merged == {"alice": {"name": "Alice Example"}}
+
+
from scripts.generate_wall_of_honour import splice_into_readme
@@ -313,14 +350,25 @@ def test_main_end_to_end(monkeypatch, tmp_path):
)
readme = tmp_path / "profile" / "README.md"
+ roster_path = tmp_path / "profile" / "wall_roster.json"
+ # Pre-seed the roster with a login no longer returned by the API
+ roster_path.parent.mkdir(parents=True, exist_ok=True)
+ roster_path.write_text('{"gone": {"name": "Departed Contributor"}}', encoding="utf-8")
monkeypatch.setenv("GITHUB_TOKEN", "fake-token")
monkeypatch.setenv("GITHUB_ORG", "BHoM")
monkeypatch.setenv("README_PATH", str(readme))
+ monkeypatch.setenv("ROSTER_PATH", str(roster_path))
exit_code = main()
assert exit_code == 0
assert readme.exists()
content = readme.read_text(encoding="utf-8")
assert "Alice Example" in content
- assert "img.shields.io/badge/contributors-1-brightgreen" in content # Only alice; bot filtered
+ assert "Departed Contributor" in content # roster-only login stays on the wall
+ assert "img.shields.io/badge/contributors-2-brightgreen" in content # alice + gone; bot filtered
assert "dependabot" not in content
+ roster = load_roster(str(roster_path))
+ assert roster == {
+ "alice": {"name": "Alice Example"},
+ "gone": {"name": "Departed Contributor"},
+ }