feat(workflow): Gitea-Wiki als generierter Spiegel von docs/ (#192)
CI / verify (push) Successful in 6m22s
CI / pwsh (push) Successful in 2m24s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped

Konverter .gitea/scripts/wiki_mirror.py, Push-Skript wiki-push.sh, Job wiki-mirror in release.yml, Dispatch-Workflow wiki-mirror.yml und --check-Schritt in ci.yml. Version 9.0.0-beta.13.

Files changed:
- .gitea/scripts/wiki-push.sh
- .gitea/scripts/wiki_mirror.py
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- .gitea/workflows/wiki-mirror.yml
- CHANGES.md
- README.md
- VERSION
- docs/dev/development.md
- tools/chemenu/tests/test_wiki_mirror.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-07 23:10:19 +02:00
1 parent 45ae04cfee
commit 90ea0eea1b
10 files changed
+869 -3

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
"""Tests for the Gitea wiki mirror: the converter `.gitea/scripts/wiki_mirror.py` and the push
script `.gitea/scripts/wiki-push.sh`. Neither is part of `tools/chemenu`; the converter is loaded
by path and the push script is run against a bare repository on disk."""
from __future__ import annotations
import importlib.util
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
SCRIPTS = REPO / ".gitea" / "scripts"
TAG_URL = "https://gitea.nehmer.net/torben/chemenu/src/tag/v1.2.3"
def _load():
spec = importlib.util.spec_from_file_location("wiki_mirror", SCRIPTS / "wiki_mirror.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
wm = _load()
def _write(root: Path, files: dict[str, str]) -> Path:
for rel, text in files.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return root
def _run(source: Path, out: Path | None, *extra: str) -> int:
argv = ["--source", str(source), "--version", "1.2.3", *extra]
if out is not None:
argv += ["--out", str(out)]
return wm.main(argv)
def _read_all(out: Path) -> dict[str, bytes]:
return {p.name: p.read_bytes() for p in sorted(out.iterdir())}
MINI = {
"README.md": "# Home\n\n[guide](docs/how-to/ingest.md) [agents](AGENTS.md)\n",
"INSTALL.md": "# Install\n",
"AGENTS.md": "# Agents\n",
"tools/CONTRACT.md": "# Contract\n",
"docs/how-to/README.md": "# How-to\n",
"docs/how-to/ingest.md": "# Ingest\n\n[up](README.md) [ref](../reference/commands/pages.md#foo)\n",
"docs/reference/commands/README.md": "# Commands\n",
"docs/reference/commands/pages.md": "# Pages\n\n## Foo\n",
"docs/dev/development.md": "# Developing\n",
}
@pytest.mark.parametrize(
"path, name",
[
("docs/how-to/ingest-a-source.md", "How-to-Ingest-a-source"),
("docs/how-to/README.md", "How-to"),
("docs/reference/commands/pages.md", "Reference-Commands-Pages"),
("docs/reference/commands/README.md", "Reference-Commands"),
("docs/dev/development.md", "Dev-Development"),
("docs/why-gates-are-code.md", "Why-gates-are-code"),
("README.md", "Home"),
("INSTALL.md", "Install"),
],
)
def test_name_rule(tmp_path, path, name):
_write(tmp_path, {"README.md": "x\n", "INSTALL.md": "x\n", path: "x\n"})
pages, errors = wm.collect_pages(str(tmp_path))
assert errors == []
assert {p.path: p.name for p in pages}[path] == name
def test_every_file_under_docs_gets_a_unique_name_and_output_is_reproducible(tmp_path):
pages, errors = wm.collect_pages(str(REPO))
assert errors == []
mirrored = {p.path for p in pages}
on_disk = {p.relative_to(REPO).as_posix() for p in (REPO / "docs").rglob("*.md")}
assert on_disk <= mirrored
names = [p.name.lower() for p in pages]
assert len(names) == len(set(names))
first, second = tmp_path / "a", tmp_path / "b"
assert _run(REPO, first) == 0
assert _run(REPO, second) == 0
assert _read_all(first) == _read_all(second)
def test_every_relative_link_in_the_output_is_a_page_or_a_tag_url(tmp_path):
out = tmp_path / "out"
assert _run(REPO, out) == 0
names = {p.stem for p in out.iterdir()}
for page in out.iterdir():
for line, in_code in wm.code_flags(page.read_text(encoding="utf-8")):
if in_code:
continue
for match in wm._LINK.finditer(wm.mask_code_spans(line)):
target = match.group(1)
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target) or target.startswith("#"):
continue
assert target.partition("#")[0] in names, (page.name, target)
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
linked = set(re.findall(r"\]\(([^)#]+)\)", sidebar))
assert linked <= names
def test_links_are_rewritten_and_everything_else_is_left_alone(tmp_path):
out = tmp_path / "out"
assert _run(_write(tmp_path / "src", MINI), out) == 0
ingest = (out / "How-to-Ingest.md").read_text(encoding="utf-8")
assert "[up](How-to)" in ingest
assert "[ref](Reference-Commands-Pages#foo)" in ingest
home = (out / "Home.md").read_text(encoding="utf-8")
assert "[guide](How-to-Ingest)" in home
assert f"[agents]({TAG_URL}/AGENTS.md)" in home
assert home.startswith(
f"*Generated from [`README.md`]({TAG_URL}/README.md) at v1.2.3 – edit the repository, not this wiki.*\n\n"
)
assert sorted(_read_all(out)) == sorted(
[
"Home.md", "Install.md", "How-to.md", "How-to-Ingest.md", "Reference-Commands.md",
"Reference-Commands-Pages.md", "Dev-Development.md", "_Sidebar.md",
]
)
def test_external_links_anchors_and_code_are_not_touched(tmp_path):
body = (
"[ext](https://example.org/a.md) [mail](mailto:a@b.c) [here](#foo)\n"
"`[inline](missing.md)` and [`code text`](AGENTS.md)\n"
"```\n[fenced](missing.md)\n```\n"
"~~~sh\n[tilde](missing.md)\n~~~\n"
" ````\n```\n[nested](missing.md)\n````\n"
)
src = _write(tmp_path / "src", {"README.md": body, "AGENTS.md": "x\n"})
out = tmp_path / "out"
assert _run(src, out) == 0
home = (out / "Home.md").read_text(encoding="utf-8").split("\n\n", 1)[1]
assert home == body.replace("[`code text`](AGENTS.md)", f"[`code text`]({TAG_URL}/AGENTS.md)")
def test_link_to_a_directory_without_a_readme_points_at_the_tag(tmp_path):
src = _write(tmp_path / "src", {"README.md": "[d](docs/) [t](tools/)\n", "tools/x.txt": "x\n", "docs/a.md": "x\n"})
out = tmp_path / "out"
assert _run(src, out) == 0
home = (out / "Home.md").read_text(encoding="utf-8")
assert f"[d]({TAG_URL}/docs)" in home and f"[t]({TAG_URL}/tools)" in home
@pytest.mark.parametrize(
"files, expect",
[
({"docs/a/b.md": "x\n", "docs/a-b.md": "x\n"}, ["docs/a/b.md", "docs/a-b.md"]),
({"docs/how-to/x.md": "x\n", "docs/How-to/x.md": "x\n"}, ["docs/how-to/x.md", "docs/How-to/x.md"]),
({"docs/install.md": "x\n"}, ["docs/install.md", "reserved"]),
({"docs/_sidebar.md": "x\n"}, ["docs/_sidebar.md", "reserved"]),
],
)
def test_a_name_collision_fails_and_names_the_paths(tmp_path, capsys, files, expect):
src = _write(tmp_path / "src", {"README.md": "x\n", "INSTALL.md": "x\n", **files})
assert _run(src, tmp_path / "out") == 1
err = capsys.readouterr().err
for fragment in expect:
assert fragment in err
assert not (tmp_path / "out").exists()
def test_a_link_to_a_missing_file_fails_and_names_both_paths(tmp_path, capsys):
src = _write(tmp_path / "src", {"README.md": "x\n", "docs/a.md": "[gone](../nowhere/x.md)\n"})
assert _run(src, tmp_path / "out") == 1
err = capsys.readouterr().err
assert "docs/a.md" in err and "nowhere/x.md" in err
def test_a_link_out_of_the_tree_fails(tmp_path, capsys):
src = _write(tmp_path / "src", {"README.md": "[x](../../etc/passwd)\n"})
assert _run(src, tmp_path / "out") == 1
assert "leaves the tree" in capsys.readouterr().err
def test_check_writes_nothing_and_fails_on_a_problem(tmp_path):
src = _write(tmp_path / "src", MINI)
assert _run(src, None, "--check") == 0
assert not (tmp_path / "out").exists()
_write(src, {"docs/bad.md": "[x](missing.md)\n"})
assert _run(src, None, "--check") == 1
def test_a_flat_docs_directory_without_areas_is_mirrored(tmp_path):
src = _write(
tmp_path / "src",
{
"README.md": "[why](docs/why.md) [dev](DEVELOPMENT.md)\n",
"DEVELOPMENT.md": "x\n",
"docs/why.md": "# Why\n\n[home](../README.md)\n",
},
)
out = tmp_path / "out"
assert _run(src, out) == 0
assert "[why](Why)" in (out / "Home.md").read_text(encoding="utf-8")
assert f"[dev]({TAG_URL}/DEVELOPMENT.md)" in (out / "Home.md").read_text(encoding="utf-8")
assert "[home](Home)" in (out / "Why.md").read_text(encoding="utf-8")
sidebar = (out / "_Sidebar.md").read_text(encoding="utf-8")
assert "* Documentation\n * [Why](Why)\n" in sidebar
def test_sidebar_order_and_nesting(tmp_path):
out = tmp_path / "out"
assert _run(_write(tmp_path / "src", MINI), out) == 0
assert (out / "_Sidebar.md").read_text(encoding="utf-8") == (
"* [Home](Home)\n"
"* [Install](Install)\n"
"* [How-to](How-to)\n"
" * [Ingest](How-to-Ingest)\n"
"* Reference\n"
" * [Commands](Reference-Commands)\n"
" * [Pages](Reference-Commands-Pages)\n"
"* Dev\n"
" * [Developing](Dev-Development)\n"
)
def test_a_non_empty_output_directory_is_refused(tmp_path, capsys):
out = tmp_path / "out"
_write(out, {"stale.md": "x\n"})
assert _run(_write(tmp_path / "src", MINI), out) == 1
assert "not empty" in capsys.readouterr().err
# --- wiki-push.sh -----------------------------------------------------------------------------
needs_tools = pytest.mark.skipif(
not all(shutil.which(t) for t in ("git", "sh", "base64", "diff", "find")),
reason="needs git and POSIX utilities",
)
def _git(*args: str, cwd: Path | None = None) -> str:
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout
class Wiki:
def __init__(self, root: Path):
self.bare = root / "wiki.git"
self.out = root / "out"
subprocess.run(["git", "init", "-q", "--bare", str(self.bare)], check=True)
def mirror(self, files: dict[str, str], version: str = "1.0.0", token: str = "t"):
shutil.rmtree(self.out, ignore_errors=True)
_write(self.out, files)
env = {**os.environ, "WIKI_REMOTE": str(self.bare), "WIKI_TOKEN": token}
if not token:
env.pop("WIKI_TOKEN")
return subprocess.run(
["sh", str(SCRIPTS / "wiki-push.sh"), str(self.out), version],
env=env, capture_output=True, text=True,
)
def commits(self) -> int:
refs = _git("--git-dir", str(self.bare), "for-each-ref")
return int(_git("--git-dir", str(self.bare), "rev-list", "--all", "--count")) if refs else 0
def files(self) -> dict[str, str]:
branch = _git("--git-dir", str(self.bare), "for-each-ref", "--format=%(refname)").split()[0]
names = _git("--git-dir", str(self.bare), "ls-tree", "-r", "--name-only", branch).split()
return {n: _git("--git-dir", str(self.bare), "show", f"{branch}:{n}") for n in names}
@needs_tools
def test_push_fills_an_empty_wiki_with_exactly_the_output(tmp_path):
wiki = Wiki(tmp_path)
result = wiki.mirror({"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"})
assert result.returncode == 0, result.stderr
assert wiki.files() == {"Home.md": "home\n", "A.md": "a\n", "_Sidebar.md": "s\n"}
assert wiki.commits() == 1
@needs_tools
def test_push_of_the_same_output_makes_no_commit(tmp_path):
wiki = Wiki(tmp_path)
files = {"Home.md": "home\n", "A.md": "a\n"}
assert wiki.mirror(files).returncode == 0
again = wiki.mirror(files)
assert again.returncode == 0
assert "Wiki already matches v1.0.0" in again.stdout
assert wiki.commits() == 1
@needs_tools
def test_push_removes_a_page_the_output_no_longer_has_and_updates_the_rest(tmp_path):
wiki = Wiki(tmp_path)
assert wiki.mirror({"Home.md": "one\n", "Gone.md": "x\n", "Keep.md": "k\n"}).returncode == 0
assert wiki.mirror({"Home.md": "two\n", "Keep.md": "k\n"}, version="2.0.0").returncode == 0
assert wiki.files() == {"Home.md": "two\n", "Keep.md": "k\n"}
assert wiki.commits() == 2
assert "Mirror docs at v2.0.0" in _git("--git-dir", str(wiki.bare), "log", "-1", "--format=%s", "--all")
@needs_tools
def test_push_without_a_token_fails_before_touching_the_wiki(tmp_path):
wiki = Wiki(tmp_path)
result = wiki.mirror({"Home.md": "home\n"}, token="")
assert result.returncode != 0
assert "WIKI_TOKEN" in result.stderr
assert wiki.commits() == 0