diff --git a/README.md b/README.md
index 89a9f343..a1e988b5 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,14 @@ Hashes document what emulator code loads and accepts, not dump provenance; that
3. **Build packs** - include baseline files plus what each platform's cores need
4. **Verify** - run platform-native checks and emulator-level validation
+## Contributors
+
+
+
+
+
+
+
## Community tools
@@ -158,4 +166,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in the [FAQ](https://abdess.github.io/retrobios/wiki/faq/#is-this-legal).
-*Auto-generated on 2026-08-23T07:25:37Z*
+*Auto-generated on 2026-08-23T07:45:09Z*
diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py
index 818ea9e2..90f2aa34 100644
--- a/scripts/generate_readme.py
+++ b/scripts/generate_readme.py
@@ -126,6 +126,34 @@ RELEASE_URL = "../../releases/latest"
REPO = "Abdess/retrobios"
+def _existing_contributor_block(readme_path: str = "README.md") -> list[str]:
+ """The contributors already published, read back from the file.
+
+ The section is the one part of this document that comes from the network.
+ A refused or rate-limited request used to leave the list empty, which
+ silently deleted the section from a published README and put the local
+ result permanently at odds with what the freshness check regenerates.
+ Keeping what is already there makes an offline run additive-only.
+ """
+ try:
+ with open(readme_path, encoding="utf-8") as handle:
+ text = handle.read()
+ except OSError:
+ return []
+ start = text.find("## Contributors")
+ if start == -1:
+ return []
+ rest = text[start:].splitlines()
+ block = []
+ for line in rest[1:]:
+ if line.startswith("## "):
+ break
+ block.append(line)
+ while block and not block[-1].strip():
+ block.pop()
+ return ["## Contributors"] + block if block else []
+
+
def fetch_contributors() -> list[dict]:
"""Fetch contributors from GitHub API, exclude bots."""
import urllib.error
@@ -518,6 +546,13 @@ def generate_readme(db: dict, platforms_dir: str) -> str:
)
contributors = fetch_contributors()
+ if not contributors:
+ # The request failed. Republish what is already there rather than
+ # dropping the section: losing it is a worse answer than a stale list.
+ kept = _existing_contributor_block("README.md")
+ if kept:
+ print("contributors: request failed, keeping the published list")
+ lines.extend(kept + [""])
if contributors:
lines.extend(
[
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index d590c713..28529226 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -957,3 +957,61 @@ class ScriptsImportThreeWays(unittest.TestCase):
def test_run_as_a_script(self):
result = self._run("scripts/list_platforms.py")
self.assertEqual(result.returncode, 0, result.stderr[-400:])
+
+
+class ContributorsSurviveAFailedRequest(unittest.TestCase):
+ """The one part of the README that comes from the network.
+
+ A refused or rate-limited request left the list empty, which deleted the
+ section from a published README. It happened during a pipeline run and
+ the result was committed, after which the freshness check regenerated the
+ section and failed on the difference. Losing the list is a worse answer
+ than publishing a stale one.
+ """
+
+ def _block(self, text: str):
+ import tempfile
+
+ sys.path.insert(0, str(ROOT / "scripts"))
+ from generate_readme import _existing_contributor_block
+
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as handle:
+ handle.write(text)
+ name = handle.name
+ try:
+ return _existing_contributor_block(name)
+ finally:
+ os.unlink(name)
+
+ def test_a_published_list_is_read_back(self):
+ text = (
+ "# Title\n\n## Contributors\n\n"
+ '
\n'
+ '
\n\n'
+ "## Community tools\n\nsomething else\n"
+ )
+ block = self._block(text)
+ self.assertEqual(block[0], "## Contributors")
+ self.assertIn('alt="a"', "\n".join(block))
+ self.assertIn('alt="b"', "\n".join(block))
+ self.assertNotIn("Community tools", "\n".join(block))
+ self.assertNotIn("something else", "\n".join(block))
+
+ def test_a_readme_without_the_section_yields_nothing(self):
+ self.assertEqual(self._block("# Title\n\n## Other\n\ntext\n"), [])
+
+ def test_an_unreadable_file_yields_nothing(self):
+ sys.path.insert(0, str(ROOT / "scripts"))
+ from generate_readme import _existing_contributor_block
+
+ self.assertEqual(_existing_contributor_block("/nonexistent/README.md"), [])
+
+ def test_the_committed_readme_still_carries_its_contributors(self):
+ """Regenerating offline must never be the reason this disappears."""
+ text = (ROOT / "README.md").read_text()
+ if "## Contributors" not in text:
+ self.skipTest("README carries no contributors section")
+ self.assertGreater(
+ text.count('