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

+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# Make the wiki repository of this repo hold exactly the converter output.
#
# wiki-push.sh <output-dir> <version>
#
# Environment:
# WIKI_TOKEN token with write access to the wiki (required)
# GITHUB_SERVER_URL set by the runner; GITHUB_REPOSITORY likewise
# WIKI_REMOTE overrides the wiki clone URL (the tests point it at a local repository)
#
# The wiki is a mirror, never a source: every file in it is replaced by the converter output, so
# a page the converter no longer produces disappears. Afterwards the working tree minus .git/ and
# the output directory are compared, and any difference fails the script before anything is
# pushed. An unchanged result ends with exit 0 and no commit.
set -eu
out="${1:?usage: wiki-push.sh <output-dir> <version>}"
version="${2:?usage: wiki-push.sh <output-dir> <version>}"
if [ -z "${WIKI_TOKEN:-}" ]; then
echo "ERROR WIKI_TOKEN is empty: the wiki mirror needs a token that may push to the wiki repository." >&2
echo " The job token (gitea.token) is tried first; if Gitea refuses it, the operator adds a" >&2
echo " repository secret with write access and the workflow passes that one instead." >&2
exit 1
fi
[ -f "$out/Home.md" ] || { echo "ERROR $out/Home.md is missing - not a converter output" >&2; exit 1; }
server="${GITHUB_SERVER_URL:-}"
repo="${GITHUB_REPOSITORY:-}"
remote="${WIKI_REMOTE:-${server}/${repo}.wiki.git}"
api="${server}/api/v1/repos/${repo}"
# The token travels in a header, not in the URL, so it appears in no git message or process list.
auth="Authorization: Basic $(printf 'wiki-mirror:%s' "$WIKI_TOKEN" | base64 | tr -d '\n')"
git_remote() { git -c "http.extraheader=${auth}" "$@"; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
wiki="$work/wiki"
if ! git_remote clone -q "$remote" "$wiki" 2>"$work/clone.err"; then
echo "Cloning ${remote} failed:"
cat "$work/clone.err"
# A wiki that has never had a page has no repository yet; the first page created through
# the API brings it into existence.
echo "Creating the first page through the API to bring the wiki repository into existence."
content="$(base64 < "$out/Home.md" | tr -d '\n')"
jq -n --arg title Home --arg content "$content" --arg message "Mirror docs at v${version}" \
'{title: $title, content_base64: $content, message: $message}' > "$work/first-page.json"
status="$(curl -sS -o "$work/first-page.out" -w '%{http_code}' -X POST "${api}/wiki/new" \
-H "Authorization: token ${WIKI_TOKEN}" -H 'Content-Type: application/json' \
--data-binary @"$work/first-page.json")"
case "$status" in
2??) ;;
*)
echo "ERROR the API refused the first page (HTTP ${status}):" >&2
cat "$work/first-page.out" >&2
exit 1
;;
esac
git_remote clone -q "$remote" "$wiki"
fi
find "$wiki" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
cp -R "$out"/. "$wiki"/
git -C "$wiki" add -A
if ! diff -r -x .git "$out" "$wiki" > "$work/diff.txt"; then
echo "ERROR the wiki working tree differs from the converter output after copying:" >&2
cat "$work/diff.txt" >&2
exit 1
fi
if git -C "$wiki" diff --cached --quiet; then
echo "Wiki already matches v${version}"
exit 0
fi
git -C "$wiki" -c user.name='Wiki Mirror' -c user.email='wiki-mirror@noreply.invalid' \
commit -q -m "Mirror docs at v${version}"
git_remote -C "$wiki" push -q origin HEAD
echo "Wiki now mirrors v${version}"
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Convert the documentation of one release tree into the flat page set of a Gitea wiki.
wiki_mirror.py --source <tree> --out <dir> --version <X.Y.Z> [--repo-url <url>] [--check]
The output directory becomes the whole content of the wiki repository, so the conversion is
deterministic: the same tree and version give the same bytes. `--check` converts in memory,
writes nothing and fails only on a name collision or an unresolvable relative link.
Plain standard library on purpose: it runs in CI before any tool environment exists, and it
must work against the tree of any past tag.
"""
from __future__ import annotations
import argparse
import os
import posixpath
import re
import sys
from urllib.parse import quote, unquote
DEFAULT_REPO_URL = "https://gitea.nehmer.net/torben/chemenu"
RESERVED_NAMES = {"home", "install", "_sidebar"}
AREA_ORDER = ("tutorials", "how-to", "reference", "explanation")
DEV_AREA = "dev"
FLAT_GROUP = "Documentation"
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
_FENCE = re.compile(r"^\s*(`{3,}|~{3,})")
_LINK = re.compile(r"""\]\(\s*([^)\s<][^)\s]*)((?:\s+(?:"[^"]*"|'[^']*'))?)\s*\)""")
_H1 = re.compile(r"^# +(.+?)\s*#*\s*$")
class Page:
def __init__(self, path: str, name: str, segs: tuple[str, ...] | None, index: bool):
self.path = path
self.name = name
self.segs = segs # sidebar position below docs/; None for Home and Install
self.index = index # a README.md: stands for its directory
def _capitalise(segment: str) -> str:
return segment[:1].upper() + segment[1:]
def collect_pages(source: str) -> tuple[list[Page], list[str]]:
"""Every page to mirror, in a stable order, plus the collisions found among their names."""
pages: list[Page] = []
if os.path.isfile(os.path.join(source, "README.md")):
pages.append(Page("README.md", "Home", None, False))
if os.path.isfile(os.path.join(source, "INSTALL.md")):
pages.append(Page("INSTALL.md", "Install", None, False))
docs = os.path.join(source, "docs")
found: list[str] = []
for base, dirs, files in os.walk(docs):
dirs.sort()
for file in sorted(files):
if file.endswith(".md"):
rel = os.path.relpath(os.path.join(base, file), source)
found.append(rel.replace(os.sep, "/"))
for path in sorted(found):
parts = path[len("docs/"):-len(".md")].split("/")
index = parts[-1] == "README"
if index:
parts = parts[:-1]
name = "-".join(_capitalise(p) for p in parts) if parts else FLAT_GROUP
pages.append(Page(path, name, tuple(parts), index))
errors: list[str] = []
seen: dict[str, str] = {}
for page in pages:
key = page.name.lower()
if key in RESERVED_NAMES and page.segs is not None:
errors.append(f"{page.path}: page name '{page.name}' is reserved for a generated page")
elif key in seen:
errors.append(
f"name collision on '{page.name}' (compared without case): {seen[key]} and {page.path}"
)
else:
seen[key] = page.path
return pages, errors
def mask_code_spans(line: str) -> str:
"""The line with every inline code span blanked to NULs, same length, so offsets still match."""
out = list(line)
n = len(line)
i = 0
while i < n:
if line[i] != "`":
i += 1
continue
j = i
while j < n and line[j] == "`":
j += 1
run = j - i
k = j
end = -1
while k < n:
if line[k] != "`":
k += 1
continue
m = k
while m < n and line[m] == "`":
m += 1
if m - k == run:
end = m
break
k = m
if end < 0:
i = j
continue
for p in range(i, end):
out[p] = "\0"
i = end
return "".join(out)
class Converter:
def __init__(self, source: str, version: str, repo_url: str, pages: list[Page]):
self.source = source
self.version = version
self.repo_url = repo_url.rstrip("/")
self.by_path = {p.path: p for p in pages}
self.errors: list[str] = []
def tag_url(self, path: str) -> str:
base = f"{self.repo_url}/src/tag/v{self.version}"
return base if path in ("", ".") else f"{base}/{quote(path, safe='/')}"
def resolve(self, src: str, target: str) -> str | None:
"""The replacement for one relative link target, or None (and an error recorded)."""
path, hash_, anchor = target.partition("#")
suffix = hash_ + anchor
path = unquote(path)
base = "" if path.startswith("/") else posixpath.dirname(src)
joined = posixpath.normpath(posixpath.join(base, path.lstrip("/")))
if joined == ".." or joined.startswith("../"):
self.errors.append(f"{src}: link '{target}' leaves the tree")
return None
page = self.by_path.get(joined)
if page is None and os.path.isdir(os.path.join(self.source, joined)):
index = "README.md" if joined == "." else f"{joined}/README.md"
page = self.by_path.get(index)
if page is not None:
return page.name + suffix
if os.path.exists(os.path.join(self.source, joined)):
return self.tag_url(joined) + suffix
self.errors.append(f"{src}: link '{target}' points at '{joined}', which does not exist in the tree")
return None
def convert_line(self, src: str, line: str) -> str:
masked = mask_code_spans(line)
edits = []
for match in _LINK.finditer(masked):
target = match.group(1)
if _SCHEME.match(target) or target.startswith(("#", "//")):
continue
replacement = self.resolve(src, line[match.start(1):match.end(1)])
if replacement is not None:
edits.append((match.start(1), match.end(1), replacement))
for start, end, replacement in reversed(edits):
line = line[:start] + replacement + line[end:]
return line
def convert(self, src: str, text: str) -> str:
return "\n".join(
line if in_code else self.convert_line(src, line) for line, in_code in code_flags(text)
)
def code_flags(text: str):
"""Each line of the text with whether it belongs to a fenced code block (fences included)."""
fence = None
for line in text.split("\n"):
m = _FENCE.match(line)
if fence is None:
if m:
fence = m.group(1)
yield line, m is not None
continue
yield line, True
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip().strip(fence[0]):
fence = None
def title_of(text: str, fallback: str) -> str:
for line, in_code in code_flags(text):
h = None if in_code else _H1.match(line)
if h:
return h.group(1).replace("[", "\\[").replace("]", "\\]")
return fallback
def sidebar(pages: list[Page], titles: dict[str, str]) -> str:
def link(page: Page) -> str:
return f"[{titles[page.path]}]({page.name})"
lines = []
home = next((p for p in pages if p.name == "Home" and p.segs is None), None)
install = next((p for p in pages if p.name == "Install" and p.segs is None), None)
for page in (home, install):
if page is not None:
lines.append(f"* {link(page)}")
docs = [p for p in pages if p.segs is not None]
page_at = {p.segs: p for p in docs}
flat = [p for p in docs if len(p.segs) == 0 or (len(p.segs) == 1 and not p.index)]
tree = [p for p in docs if p not in flat]
children: dict[tuple[str, ...], set[tuple[str, ...]]] = {}
for page in tree:
for i in range(1, len(page.segs) + 1):
children.setdefault(page.segs[: i - 1], set()).add(page.segs[:i])
def label(key: tuple[str, ...]) -> str:
page = page_at.get(key)
return link(page) if page else _capitalise(key[-1])
def render(key: tuple[str, ...], depth: int) -> None:
lines.append(" " * depth + f"* {label(key)}")
for child in sorted(children.get(key, ()), key=lambda k: k[-1].lower()):
render(child, depth + 1)
if flat:
lines.append(f"* {FLAT_GROUP}")
for page in sorted(flat, key=lambda p: p.name.lower()):
lines.append(f" * {link(page)}")
tops = children.get((), set())
known = [(s,) for s in AREA_ORDER if (s,) in tops]
rest = sorted((k for k in tops if k[0] not in AREA_ORDER and k[0] != DEV_AREA), key=lambda k: k[0].lower())
last = [(DEV_AREA,)] if (DEV_AREA,) in tops else []
for key in known + rest + last:
render(key, 0)
return "\n".join(lines) + "\n"
def build(source: str, version: str, repo_url: str) -> tuple[dict[str, str], list[str]]:
"""The wiki as {file name: content}, and every error found while building it."""
pages, errors = collect_pages(source)
converter = Converter(source, version, repo_url, pages)
files: dict[str, str] = {}
titles: dict[str, str] = {}
for page in pages:
with open(os.path.join(source, page.path), encoding="utf-8", newline="") as fh:
text = fh.read()
titles[page.path] = title_of(text, page.name)
notice = (
f"*Generated from [`{page.path}`]({converter.tag_url(page.path)}) at v{version}"
" – edit the repository, not this wiki.*"
)
files[f"{page.name}.md"] = notice + "\n\n" + converter.convert(page.path, text)
files["_Sidebar.md"] = sidebar(pages, titles)
return files, errors + converter.errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--source", required=True, help="the tree to mirror")
parser.add_argument("--out", help="empty or missing directory that receives the wiki content")
parser.add_argument("--version", required=True, help="X.Y.Z of the tree; names the tag links point at")
parser.add_argument("--repo-url", default=DEFAULT_REPO_URL)
parser.add_argument("--check", action="store_true", help="convert, write nothing, fail on a problem")
args = parser.parse_args(argv)
if not args.check and not args.out:
parser.error("--out is required unless --check is given")
if not os.path.isdir(args.source):
print(f"ERROR --source {args.source} is not a directory", file=sys.stderr)
return 1
files, errors = build(args.source, args.version, args.repo_url)
if errors:
for error in errors:
print(f"ERROR {error}", file=sys.stderr)
return 1
if not args.check:
if os.path.isdir(args.out) and os.listdir(args.out):
print(f"ERROR --out {args.out} is not empty", file=sys.stderr)
return 1
os.makedirs(args.out, exist_ok=True)
for name, content in files.items():
with open(os.path.join(args.out, name), "w", encoding="utf-8", newline="") as fh:
fh.write(content)
print(f"{len(files)} wiki files for v{args.version}" + (" (checked, nothing written)" if args.check else ""))
return 0
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -194,6 +194,14 @@ jobs:
tools/wikitool instructions verify tools/wikitool instructions verify
tools/wikitool lint --fail-on-error tools/wikitool lint --fail-on-error
- name: Wiki mirror converts the docs
# The Gitea wiki mirrors docs/ at every release (release.yml, job wiki-mirror). A link the
# converter cannot resolve, or two pages that would share a wiki name, is cheaper to hear
# about here than when the release is cut. Writes nothing.
run: |
set -eu
python3 .gitea/scripts/wiki_mirror.py --check --source . --version "$(tr -d '[:space:]' < VERSION)"
- name: Version gate - name: Version gate
# A stack change with no version bump cannot be released, because the # A stack change with no version bump cannot be released, because the
# release would carry a change nobody named. Scoped to what # release would carry a change nobody named. Scoped to what
+51
View File
@@ -17,6 +17,13 @@
# instance issues - not a 1Password secret. Nothing here reaches outside the # instance issues - not a 1Password secret. Nothing here reaches outside the
# instance, so the Zero-Trust secret path that the container-build workflows # instance, so the Zero-Trust secret path that the container-build workflows
# use has nothing to carry. # use has nothing to carry.
#
# `wiki-mirror` is the second job: once `release` has published a release, it
# mirrors that tree's docs into the Gitea wiki (.gitea/scripts/wiki_mirror.py
# converts, wiki-push.sh pushes; wiki-mirror.yml does the same by hand for any
# tag). It is a separate job so that a failing mirror leaves the release and its
# tag standing: the release is the product, the wiki a view of it. It does not
# run for a `-beta.N` candidate, for which `release` sets `skip`.
name: Release name: Release
@@ -38,6 +45,10 @@ jobs:
image: debian:trixie-slim image: debian:trixie-slim
permissions: permissions:
contents: write contents: write
outputs:
skip: ${{ steps.version.outputs.skip }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
env: env:
WIKITOOL_SESSION_ID: release-${{ github.run_id }} WIKITOOL_SESSION_ID: release-${{ github.run_id }}
WIKI_TRACE_DIR: /tmp/wikitool-trace WIKI_TRACE_DIR: /tmp/wikitool-trace
@@ -233,3 +244,43 @@ jobs:
done done
echo "Done: ${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" echo "Done: ${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
wiki-mirror:
# Runs only after `release` succeeded and created a release (a candidate
# sets skip=true). `release` never depends on this job.
needs: release
if: needs.release.outputs.skip != 'true'
runs-on: linux-docker
container:
image: debian:trixie-slim
permissions:
contents: write
env:
VERSION: ${{ needs.release.outputs.version }}
TAG: ${{ needs.release.outputs.tag }}
PUBLIC_BASE_URL: https://gitea.nehmer.net
steps:
- name: System dependencies
run: |
set -eu
apt-get update -qq
apt-get install -y --no-install-recommends python3 git nodejs ca-certificates curl jq
rm -rf /var/lib/apt/lists/*
- uses: actions/checkout@v7
- name: Mirror the docs into the wiki
env:
WIKI_TOKEN: ${{ gitea.token }}
run: |
set -eu
python3 .gitea/scripts/wiki_mirror.py --source . --out /tmp/wiki-out \
--version "$VERSION" --repo-url "${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}"
.gitea/scripts/wiki-push.sh /tmp/wiki-out "$VERSION"
- name: Say which half failed
if: failure()
run: |
echo "Release ${TAG} is published; only the wiki mirror failed."
echo "The release and its tag stand. Dispatch wiki-mirror.yml with tag ${TAG} to retry the mirror."
+78
View File
@@ -0,0 +1,78 @@
# Mirror the docs of one release tag into the Gitea wiki, by hand.
#
# The same two scripts as the `wiki-mirror` job in release.yml, started on demand:
# to fill the wiki for the first time and look at it before the next release,
# and to catch up a mirror that failed after its release was already published.
# The wiki is a mirror and never a source - every run replaces its whole content,
# and nothing but CI writes to it.
#
# The converter and the push script come from the commit this workflow runs on
# (main); the docs come from the tag, checked out beside it. That way an old tag,
# whose tree predates the converter, can still be mirrored.
#
# Input `tag` defaults to the latest release, read from the release API.
name: Wiki mirror
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to mirror (default: the latest release)'
required: false
default: ''
jobs:
mirror:
runs-on: linux-docker
container:
image: debian:trixie-slim
permissions:
contents: write
env:
PUBLIC_BASE_URL: https://gitea.nehmer.net
steps:
- name: System dependencies
run: |
set -eu
apt-get update -qq
apt-get install -y --no-install-recommends python3 git nodejs ca-certificates curl jq
rm -rf /var/lib/apt/lists/*
- uses: actions/checkout@v7
- name: Resolve the tag
id: tag
env:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TOKEN: ${{ gitea.token }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
set -eu
tag="$INPUT_TAG"
if [ -z "$tag" ]; then
tag="$(curl -sS -f -H "Authorization: token ${TOKEN}" "${API}/releases/latest" | jq -r '.tag_name')"
fi
case "$tag" in
v[0-9]*) ;;
*) echo "Tag '${tag}' is not a release tag (vX.Y.Z)."; exit 1 ;;
esac
echo "Mirroring ${tag}."
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
path: tag-tree
- name: Mirror the docs into the wiki
env:
VERSION: ${{ steps.tag.outputs.version }}
WIKI_TOKEN: ${{ gitea.token }}
run: |
set -eu
python3 .gitea/scripts/wiki_mirror.py --source tag-tree --out /tmp/wiki-out \
--version "$VERSION" --repo-url "${PUBLIC_BASE_URL}/${GITHUB_REPOSITORY}"
.gitea/scripts/wiki-push.sh /tmp/wiki-out "$VERSION"
+2 -1
View File
@@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
--- ---
## 9.0.0-beta.12 - 2026-10-07 - tools/README.md: Asset-Modus beschreibt Entpacken in den eigenen Ordner, Wiederholung und Zurückrollen ## 9.0.0-beta.13 - 2026-10-07 - Gitea-Wiki als generierter Spiegel von docs/: Konverter, Push-Skript, Workflows
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -42,6 +42,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
- tools/README.md module tree names reference_doc.py and the fourth view cli_contract renders - tools/README.md module tree names reference_doc.py and the fourth view cli_contract renders
- Rebase-Review Gate: Anzeigename in Hilfe- und Fehlertexten von sync und publish angeglichen - Rebase-Review Gate: Anzeigename in Hilfe- und Fehlertexten von sync und publish angeglichen
- tools/README.md: Asset-Modus beschreibt Entpacken in den eigenen Ordner, Wiederholung und Zurückrollen - tools/README.md: Asset-Modus beschreibt Entpacken in den eigenen Ordner, Wiederholung und Zurückrollen
- Gitea-Wiki als generierter Spiegel von docs/: Konverter, Push-Skript, Workflows
<!-- /wikitool:bumps --> <!-- /wikitool:bumps -->
### Tutorials: installation in English, and a first source to a first answer (#191) ### Tutorials: installation in English, and a first source to a first answer (#191)
+5
View File
@@ -59,6 +59,11 @@ Most days you need three of the how-to guides: [ingest a source](docs/how-to/ing
[query the wiki](docs/how-to/query-the-wiki.md) and [maintain it](docs/how-to/maintain-the-wiki.md). [query the wiki](docs/how-to/query-the-wiki.md) and [maintain it](docs/how-to/maintain-the-wiki.md).
How the parts fit together is [docs/explanation/architecture.md](docs/explanation/architecture.md). How the parts fit together is [docs/explanation/architecture.md](docs/explanation/architecture.md).
<!-- dist:strip-start -->
The Gitea wiki of this repository shows these pages for the latest release. It is generated from
`docs/` and not edited; how it is built is in [docs/dev/development.md](docs/dev/development.md).
<!-- dist:strip-end -->
## Your first ingestion ## Your first ingestion
Once the instance runs, [From your first source to your first answer](docs/tutorials/first-source-to-first-answer.md) Once the instance runs, [From your first source to your first answer](docs/tutorials/first-source-to-first-answer.md)
+1 -1
View File
@@ -1 +1 @@
9.0.0-beta.12 9.0.0-beta.13
+36 -1
View File
@@ -20,6 +20,7 @@ them. The reason is a comment at `dist_cmd.DOCS_DEV_ONLY`, so that a later sessi
- [A release feed in a non-public repository](#a-release-feed-in-a-non-public-repository) - [A release feed in a non-public repository](#a-release-feed-in-a-non-public-repository)
- [The release workflow](#the-release-workflow) - [The release workflow](#the-release-workflow)
- [The CI half](#the-ci-half) - [The CI half](#the-ci-half)
- [The Gitea wiki](#the-gitea-wiki)
- [How much of the stack the suite reaches](#how-much-of-the-stack-the-suite-reaches) - [How much of the stack the suite reaches](#how-much-of-the-stack-the-suite-reaches)
- [Stack development as a session type of its own](#stack-development-as-a-session-type-of-its-own) - [Stack development as a session type of its own](#stack-development-as-a-session-type-of-its-own)
<!-- /wikitool:toc --> <!-- /wikitool:toc -->
@@ -155,6 +156,10 @@ the order a session actually goes through it:
cause, publish, and start `release.yml` on `main` via `workflow_dispatch`. The check for an cause, publish, and start `release.yml` on `main` via `workflow_dispatch`. The check for an
existing release prevents a second one. existing release prevents a second one.
A second job, `wiki-mirror`, runs after a suffix-free release and mirrors the docs of the
release tree into the Gitea wiki ([The Gitea wiki](#the-gitea-wiki) below). It is a job of its
own, so a failing mirror leaves the release and its tag standing and marks only itself red.
The three check commands are in step 3 above; what each of them checks is in The three check commands are in step 3 above; what each of them checks is in
[tools/CONTRACT.md](../../tools/CONTRACT.md), where `docs verify` holds it against the actual [tools/CONTRACT.md](../../tools/CONTRACT.md), where `docs verify` holds it against the actual
CLI. It is deliberately **not** repeated here: a second description of the same commands is CLI. It is deliberately **not** repeated here: a second description of the same commands is
@@ -170,7 +175,8 @@ runs the test suite, `docs verify`, `instructions verify` and a complete `setup-
replay: a locally built release tarball, the preflight script with `--archive` in an empty replay: a locally built release tarball, the preflight script with `--archive` in an empty
directory, then the instruction's steps - the same path a new user actually takes, just without directory, then the instruction's steps - the same path a new user actually takes, just without
the download. `.gitea/workflows/nightly.yml` is the drift check against time rather than against the download. `.gitea/workflows/nightly.yml` is the drift check against time rather than against
a commit. `.gitea/workflows/release.yml` is step 6 above. a commit. `.gitea/workflows/release.yml` is step 6 above, and `.gitea/workflows/wiki-mirror.yml`
refills the Gitea wiki by hand (see below).
Three more workflows carry the live tests of the tracker adapters (Super Productivity, CalDAV): Three more workflows carry the live tests of the tracker adapters (Super Productivity, CalDAV):
`ci.yml` runs the CalDAV half in a step of its own against Radicale as a process, `ci.yml` runs the CalDAV half in a step of its own against Radicale as a process,
@@ -182,6 +188,35 @@ repository by hand. What the suite writes, how to run it against a tracker of yo
a red run means is in a red run means is in
[instructions/dev/tracker-testing.md](../../instructions/dev/tracker-testing.md). [instructions/dev/tracker-testing.md](../../instructions/dev/tracker-testing.md).
### The Gitea wiki
The wiki of `torben/chemenu` shows the documentation of the **latest release**. It is generated
and never edited: every run replaces its whole content, so a change made in the wiki is gone at
the next mirror, and nothing but CI writes to it (a push to the wiki repository by a session
would sit next to `publish`, which AGENTS.md invariant 5 forbids).
`.gitea/scripts/wiki_mirror.py` converts a tree into the wiki's page set: every `*.md` under
`docs/` (the `dev/` pages included), plus `README.md` as `Home` and `INSTALL.md` as `Install`.
Wiki pages in sub-directories are not listed reliably, so the names are flat: the path below
`docs/` with `.md` dropped, each segment capitalised and joined by `-`
(`docs/how-to/ingest-a-source.md` becomes `How-to-Ingest-a-source`; a `README.md` stands for its
directory). Two sources that would get the same name, compared without case, abort the run.
Relative links between mirrored files become wiki page names, relative links to anything else
become links to that file at the release tag, and a link whose target does not exist aborts the
run. A `_Sidebar` is generated, and every page opens with a line saying which file at which
version it came from. `.gitea/scripts/wiki-push.sh` then makes the wiki repository hold exactly
that output - a page the converter no longer produces is removed - and does not commit when
nothing changed.
Two things start it: the `wiki-mirror` job of `release.yml`, after each release (never for a
candidate), and `.gitea/workflows/wiki-mirror.yml` by hand with `workflow_dispatch`. The manual
workflow takes a `tag` (default: the latest release) and runs the converter and push script of
`main` against that tag's tree. Use it to fill the wiki before the first release that carries the
job, or to catch up a mirror that failed after its release was published. `ci.yml` runs
`wiki_mirror.py --check` against the working tree on every push, so a link the mirror could not
resolve fails there rather than at the release. The scripts live under `.gitea/`, which
`dist export` does not copy, so none of this reaches an instance.
### How much of the stack the suite reaches ### How much of the stack the suite reaches
Coverage is measured in CI - `pytest --cov`, config in `tools/.coveragerc`, HTML and XML Coverage is measured in CI - `pytest --cov`, config in `tools/.coveragerc`, HTML and XML
+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