Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/site-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Site Sync

on:
push:
branches: [main]
paths:
- "data/site.json"
- "index.html"
- "scripts/sync-site.py"
- ".github/workflows/site-sync.yml"
Comment on lines +6 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include index.html in push path triggers

The push trigger only watches data/site.json, scripts/sync-site.py, and the workflow file, so a direct push that edits only index.html will not run this workflow at all. That allows generated content drift on main with no check or auto-repair, which undermines the intended single-source-of-truth enforcement.

Useful? React with 👍 / 👎.

pull_request:
paths:
- "data/site.json"
- "index.html"
- "scripts/sync-site.py"
workflow_dispatch:

permissions:
contents: write

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Verify (pull requests)
if: github.event_name == 'pull_request'
run: |
python3 scripts/test_sync_site.py
python3 scripts/sync-site.py --check

- name: Sync, test, and commit (push / manual)
if: github.event_name != 'pull_request'
run: |
# Sync first so the in-sync guard test passes on the exact drift it repairs.
python3 scripts/sync-site.py
python3 scripts/test_sync_site.py
if ! git diff --quiet index.html; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add index.html
git commit -m "chore: sync landing page from data/site.json [skip ci]"
git push
fi
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
17 changes: 17 additions & 0 deletions data/site.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"_comment": "Single source of truth for the landing page's dynamic data. Run scripts/sync-site.py (or the site-sync workflow) to inject these into index.html. Edit values here, never inline in index.html.",
"values": {
"synced": "2026-05-24"
},
"organs": [
{ "label": "ORGAN-I (Theoria)", "url": "https://organvm-i-theoria.github.io/" },
{ "label": "ORGAN-II (Poiesis)", "url": "https://organvm-ii-poiesis.github.io/" },
{ "label": "ORGAN-III (Ergon)", "url": "https://organvm-iii-ergon.github.io/" },
{ "label": "ORGAN-IV (Taxis)", "url": "https://organvm-iv-taxis.github.io/" },
{ "label": "ORGAN-V (Logos)", "url": "https://organvm-v-logos.github.io/" },
{ "label": "ORGAN-VI (Koinonia)", "url": "https://organvm-vi-koinonia.github.io/" },
{ "label": "ORGAN-VII (Kerygma)", "url": "https://organvm-vii-kerygma.github.io/" },
{ "label": "META-ORGANVM", "url": "https://meta-organvm.github.io/" },
{ "label": "Personal Profile", "url": "https://4444j99.github.io/", "active": true }
]
}
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<nav>
<h2>ORGANVM Ecosystem</h2>
<ul>
<!-- organs:start -->
<li><a href="https://organvm-i-theoria.github.io/" class="">ORGAN-I (Theoria)</a></li>
<li><a href="https://organvm-ii-poiesis.github.io/" class="">ORGAN-II (Poiesis)</a></li>
<li><a href="https://organvm-iii-ergon.github.io/" class="">ORGAN-III (Ergon)</a></li>
Expand All @@ -40,6 +41,7 @@ <h2>ORGANVM Ecosystem</h2>
<li><a href="https://organvm-vii-kerygma.github.io/" class="">ORGAN-VII (Kerygma)</a></li>
<li><a href="https://meta-organvm.github.io/" class="">META-ORGANVM</a></li>
<li><a href="https://4444j99.github.io/" class="active">Personal Profile</a></li>
<!-- organs:end -->
</ul>
</nav>
<main>
Expand Down Expand Up @@ -90,7 +92,7 @@ <h1>Personal Profile</h1>
</a>
</div>
<footer style="margin-top: 6rem; padding-top: 2rem; border-top: 1px solid var(--border); color: #999; font-size: 0.8rem; text-align: center;">
ORGANVM Distributed Intelligence | System Synchronization: 2026-05-24
ORGANVM Distributed Intelligence | System Synchronization: <!-- v:synced -->2026-05-24<!-- /v -->
</footer>
</main>
</body>
Expand Down
121 changes: 121 additions & 0 deletions scripts/sync-site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
sync-site — render the landing page's dynamic data from a single source.

Reads data/site.json and injects it into index.html:
* inline `<!-- v:KEY -->...<!-- /v -->` markers <- data["values"][KEY]
* the `<!-- organs:start -->...<!-- organs:end -->` block <- data["organs"]

Usage:
python3 scripts/sync-site.py # rewrite index.html in place
python3 scripts/sync-site.py --check # exit 1 if index.html is out of sync

Environment overrides:
SITE_DATA — path to the data file (default: data/site.json)
SITE_HTML — path to the HTML file (default: index.html)

Pure standard library — no third-party dependencies.
"""

import argparse
import html
import json
import os
import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent

VALUE = re.compile(r"(<!-- v:([A-Za-z0-9_]+) -->)(.*?)(<!-- /v -->)", re.DOTALL)


def _disp(path: Path) -> str:
"""Path relative to the repo root for messages; absolute if outside it."""
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)
# Capture the leading indent on the start-marker line so the regenerated block
# matches the surrounding HTML's indentation rather than a hardcoded width.
ORGANS = re.compile(r"(?P<indent>[^\S\n]*)<!-- organs:start -->.*?<!-- organs:end -->",
re.DOTALL)


def render_organs(organs: list[dict], indent: str) -> str:
"""Render the nav <li> items at the given indent."""
lines = []
for o in organs:
cls = "active" if o.get("active") else ""
url = html.escape(str(o["url"]), quote=True)
label = html.escape(str(o["label"]))
lines.append(f'{indent}<li><a href="{url}" class="{cls}">{label}</a></li>')
return "\n".join(lines)


def render(text: str, data: dict) -> tuple[str, list[str]]:
warnings: list[str] = []
values = {k: ("" if v is None else str(v)) for k, v in (data.get("values") or {}).items()}
seen: set[str] = set()

def vrepl(m: re.Match) -> str:
key = m.group(2)
seen.add(key)
if key not in values:
warnings.append(f"marker '{key}' has no entry in data['values']")
return m.group(0)
return f"{m.group(1)}{html.escape(values[key])}{m.group(4)}"

text = VALUE.sub(vrepl, text)
for key in values:
if key not in seen:
warnings.append(f"value '{key}' has no matching marker in the HTML")

if "organs" in data:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat missing organs data as an out-of-sync condition

render() only validates the nav block when the organs key exists, so if data/site.json drops or misspells organs, the existing HTML nav is left untouched and no warning is emitted. In --check mode this becomes a false “in sync” result even though the single source of truth for organ links is missing, which undermines the drift detection this script is intended to enforce.

Useful? React with 👍 / 👎.

if ORGANS.search(text):
def repl(m: re.Match) -> str:
indent = m.group("indent")
items = render_organs(data["organs"], indent)
return f"{indent}<!-- organs:start -->\n{items}\n{indent}<!-- organs:end -->"
text = ORGANS.sub(repl, text)
else:
warnings.append("no <!-- organs:start/end --> block found in the HTML")

return text, warnings


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true",
help="verify index.html is in sync; exit 1 on drift (no write)")
args = parser.parse_args()

data_path = Path(os.environ.get("SITE_DATA") or REPO_ROOT / "data/site.json")
html_path = Path(os.environ.get("SITE_HTML") or REPO_ROOT / "index.html")

data = json.loads(data_path.read_text(encoding="utf-8"))
original = html_path.read_text(encoding="utf-8")
new_text, warnings = render(original, data)
for w in warnings:
print(f"warn: {w}", file=sys.stderr)

if args.check:
# Warnings (e.g. a deleted marker leaving a value with nowhere to render)
# are drift too, even when the text happens to be unchanged.
if warnings or new_text != original:
print(f"error: {_disp(html_path)} is out of sync with {_disp(data_path)} — "
f"run `python3 scripts/sync-site.py`", file=sys.stderr)
return 1
print(f"{_disp(html_path)} is in sync.")
return 0
Comment on lines +102 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail check mode when required markers are missing

--check only compares new_text to original, so it returns success even when rendering emits warnings that the HTML no longer contains required markers. If index.html loses <!-- v:... --> or <!-- organs:start/end -->, no replacement occurs, new_text == original, and CI reports “in sync” while synchronization is actually broken. This creates a false negative that defeats the single-source-of-truth guardrail.

Useful? React with 👍 / 👎.


if new_text != original:
html_path.write_text(new_text, encoding="utf-8")
print(f"updated {_disp(html_path)}")
else:
print(f"{_disp(html_path)} already in sync — no change.")
return 0


if __name__ == "__main__":
sys.exit(main())
102 changes: 102 additions & 0 deletions scripts/test_sync_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Tests for scripts/sync-site.py.

Run: python3 scripts/test_sync_site.py
"""

import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path

_HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("sync_site", _HERE / "sync-site.py")
ss = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ss)


class RenderOrgansTests(unittest.TestCase):
def test_active_and_inactive_classes(self):
out = ss.render_organs([
{"label": "A", "url": "https://a/"},
{"label": "B", "url": "https://b/", "active": True},
], "")
self.assertIn('<li><a href="https://a/" class="">A</a></li>', out)
self.assertIn('<li><a href="https://b/" class="active">B</a></li>', out)

def test_escapes_special_chars(self):
out = ss.render_organs([{"label": "X & <Y>", "url": "https://x/?a=1&b=2"}], "")
self.assertIn("X &amp; &lt;Y&gt;", out)
self.assertIn("a=1&amp;b=2", out)

def test_indent_applied(self):
out = ss.render_organs([{"label": "A", "url": "https://a/"}], " ")
self.assertTrue(out.startswith(' <li>'))


class RenderTests(unittest.TestCase):
def test_value_marker_replaced(self):
text = "d: <!-- v:synced -->old<!-- /v -->"
out, warns = ss.render(text, {"values": {"synced": "2026-05-24"}})
self.assertEqual(out, "d: <!-- v:synced -->2026-05-24<!-- /v -->")
self.assertEqual(warns, [])

def test_organs_block_regenerated(self):
text = "<!-- organs:start -->stale<!-- organs:end -->"
out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]})
self.assertIn("<!-- organs:start -->", out)
self.assertIn('<li><a href="https://a/" class="">A</a></li>', out)
self.assertIn("<!-- organs:end -->", out)
self.assertNotIn("stale", out)

def test_organs_indent_derived_from_marker(self):
text = " <!-- organs:start -->old<!-- organs:end -->"
out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]})
self.assertTrue(out.startswith(" <!-- organs:start -->"))
self.assertIn('\n <li><a href="https://a/" class="">A</a></li>\n', out)
self.assertIn("\n <!-- organs:end -->", out)

def test_orphan_value_and_marker_warn(self):
_, warns = ss.render("<!-- v:foo -->x<!-- /v -->", {"values": {"bar": "1"}})
self.assertTrue(any("foo" in w for w in warns))
self.assertTrue(any("bar" in w for w in warns))

def test_idempotent(self):
data = {"values": {"synced": "2026-05-24"},
"organs": [{"label": "A", "url": "https://a/", "active": True}]}
seed = ("x <!-- v:synced -->0<!-- /v --> "
"<!-- organs:start -->z<!-- organs:end -->")
once, _ = ss.render(seed, data)
twice, _ = ss.render(once, data)
self.assertEqual(once, twice)

def test_check_fails_when_marker_missing(self):
# A value with no marker in the HTML must fail --check, not pass silently.
import os
with tempfile.TemporaryDirectory() as d:
dp = Path(d) / "site.json"
hp = Path(d) / "index.html"
dp.write_text(json.dumps({"values": {"synced": "2026-05-24"}}), encoding="utf-8")
hp.write_text("<html>no markers here</html>", encoding="utf-8")
os.environ["SITE_DATA"] = str(dp)
os.environ["SITE_HTML"] = str(hp)
old_argv = sys.argv
try:
sys.argv = ["sync-site.py", "--check"]
self.assertEqual(ss.main(), 1)
finally:
sys.argv = old_argv
del os.environ["SITE_DATA"]
del os.environ["SITE_HTML"]

def test_real_index_in_sync(self):
data = json.loads((ss.REPO_ROOT / "data/site.json").read_text(encoding="utf-8"))
original = (ss.REPO_ROOT / "index.html").read_text(encoding="utf-8")
out, _ = ss.render(original, data)
self.assertEqual(out, original, "index.html is out of sync with data/site.json")
Comment on lines +97 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Assert sync warnings are empty in real-index guard

The test_real_index_in_sync guard only checks out == original and ignores warnings from render(). In the push/manual workflow path, this lets CI pass even if required markers were removed from index.html (for example <!-- v:synced --> or <!-- organs:start/end -->): sync-site.py warns but exits 0 in write mode, and this test still passes because no textual replacement occurs. That means the auto-repair job can report success while synchronization is actually broken on main.

Useful? React with 👍 / 👎.



if __name__ == "__main__":
unittest.main()
Loading