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
35 changes: 32 additions & 3 deletions .github/workflows/check-wiki.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Check wiki matches the API
name: Check wiki

on:
push:
Expand All @@ -7,8 +7,8 @@ on:
workflow_dispatch:

jobs:
check:
name: Wiki drift check
drift:
name: Wiki matches the API
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -19,3 +19,32 @@ jobs:

- name: Check every endpoint is documented and every documented path exists
run: python3 tools/check_wiki.py

wikijs:
name: Wiki.js stack serves the pages with no login
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.14'

- name: Start Wiki.js and PostgreSQL
run: docker compose up -d db wiki

# Runs in the foreground so a bootstrap failure fails the job here,
# rather than showing up later as an empty wiki.
- name: Bootstrap
run: docker compose run --rm bootstrap

- name: Check every page is served anonymously
run: python3 tools/check_wikijs.py

- name: Logs
if: failure()
run: docker compose logs --no-color

- name: Tear down
if: always()
run: docker compose down -v
76 changes: 71 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,70 @@ Markdown with Wiki.js front matter and are rendered at
| `Configuration.md` | Every setting and where it can come from |
| `Deployment.md` | Production deployment |

## Reading it locally
## Running the wiki locally

```bash
docker compose up -d
```

Then open **http://localhost:3000**. That is a self-hosted [Wiki.js](https://js.wiki)
serving this repository's pages — the same software the published wiki runs on, so a page
looks here exactly as it will look once published.

**No login.** The setup wizard is completed for you and guests can read every page
anonymously. There is nothing to click through and no account to create.

Port 3000 taken?

```bash
WIKI_PORT=3030 docker compose up -d
```

### What comes up

| Service | Role |
|---|---|
| `wiki` | Wiki.js 2.5 |
| `db` | PostgreSQL 16, its backing store |
| `bootstrap` | Runs once and exits — completes setup, imports the pages, confirms guest read |

Your checkout is mounted **read-only**, so Wiki.js can import from it but never writes back
and never dirties your working tree.

An admin account exists because Wiki.js requires one
(`admin@opentaberna.local` / `opentaberna-local-admin`), but nothing asks you to use it.
It is a local development stack on a throwaway database — do not expose it to a network.

### Refreshing after an edit

Pages are imported into Wiki.js, so editing a `.md` file does not change what is served
until you re-import:

```bash
docker compose run --rm --no-deps bootstrap
```

That is idempotent — it re-imports the content and repairs the configuration if anything
has drifted.

### Starting over

```bash
docker compose down -v # -v drops the database, so setup runs again
```

## Quick preview without Docker

```bash
python3 tools/serve.py # http://localhost:8090
```

Renders the Markdown with Mermaid diagrams and working internal links, so a change can be
checked before it is published. Standard library only — no install step. Marked and
Mermaid load from a CDN, so the first page view needs a network connection.
A lighter alternative when you only want to eyeball a change: it renders the Markdown with
Mermaid diagrams and working internal links. Standard library only, no install step. Marked
and Mermaid load from a CDN, so the first page view needs a network connection.

It is a preview shim, not Wiki.js — use `docker compose up -d` when it matters how the page
will really look.

## Keeping it honest

Expand All @@ -44,7 +99,18 @@ It fails when
2. a page documents a path the API does not serve, or
3. an internal link points at a page that does not exist.

CI runs it on every push and pull request.
`tools/check_wikijs.py` covers the other half — that the stack above still serves every page
without a login:

```bash
docker compose up -d
python3 tools/check_wikijs.py
```

It fails when a page is missing, returns a non-200, or bounces an anonymous visitor to the
login screen.

CI runs both on every push and pull request.

### When the API changes

Expand Down
66 changes: 66 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Self-hosted Wiki.js serving this repository's pages.
#
# docker compose up -d → http://localhost:3000
#
# No login: the setup wizard is completed automatically and guests can read
# every page anonymously. See README.md.
#
# Port 3000 already taken? WIKI_PORT=3030 docker compose up -d

name: opentaberna-wiki

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: wiki
POSTGRES_USER: wiki
# Local-only database, never exposed outside the compose network.
POSTGRES_PASSWORD: wiki
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wiki -d wiki"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped

wiki:
image: ghcr.io/requarks/wiki:2.5
depends_on:
db:
condition: service_healthy
environment:
DB_TYPE: postgres
DB_HOST: db
DB_PORT: 5432
DB_NAME: wiki
DB_USER: wiki
DB_PASS: wiki
volumes:
# The repository itself, read-only. Wiki.js imports the Markdown from
# here; it never writes back, so your checkout stays clean.
- .:/wiki-content:ro
ports:
- "${WIKI_PORT:-3000}:3000"
restart: unless-stopped

# Runs once, then exits. Completes the setup wizard, imports the pages and
# confirms guests can read them. Safe to re-run — see "Refreshing" in README.
bootstrap:
image: python:3.13-alpine
depends_on:
- wiki
environment:
WIKI_URL: http://wiki:3000
WIKI_CONTENT_PATH: /wiki-content
ADMIN_EMAIL: admin@opentaberna.local
ADMIN_PASSWORD: opentaberna-local-admin
volumes:
- ./wikijs/bootstrap.py:/bootstrap.py:ro
command: ["python3", "/bootstrap.py"]
restart: "no"

volumes:
db_data:
138 changes: 138 additions & 0 deletions tools/check_wikijs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Check the Wiki.js stack serves this repository's pages with no login.

Assumes the stack is already up:

docker compose up -d
python3 tools/check_wikijs.py

Fails when

1. a page in the repository is missing from Wiki.js,
2. a page does not return 200 to an anonymous request,
3. a page's own title is absent from what is served, or
4. anonymous access is bounced to a login screen.

Point 4 is the one that guards "no auth". Wiki.js answers a page a guest may not
read with the login screen under a 200, so status alone proves nothing — the
title has to actually be in the body.

Override the base URL with WIKI_URL when not on the default port:

WIKI_PORT=3030 docker compose up -d
WIKI_URL=http://localhost:3030 python3 tools/check_wikijs.py
"""

from __future__ import annotations

import html
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
WIKI_URL = os.environ.get("WIKI_URL", "http://localhost:3000").rstrip("/")

# Present in the repository, deliberately not published as a wiki page.
NOT_WIKI_PAGES = {"README"}

LOGIN_MARKERS = ("login-container", "loginBgUrl", "Sign In")


class NoRedirect(urllib.request.HTTPRedirectHandler):
"""
Refuse to follow redirects.

Wiki.js bounces a guest who may not read a page to /login with a 302. Left
to follow it, urllib lands on the login page, which returns 200 and carries
the site title — so a redirect would read as a pass. A redirect is the
failure, so it has to be seen rather than followed.
"""

def redirect_request(self, req, fp, code, msg, headers, newurl):
return None


OPENER = urllib.request.build_opener(NoRedirect)


def pages() -> list[tuple[str, str]]:
"""(wiki path, title) for every page the repository publishes."""
found = []
for path in sorted(ROOT.rglob("*.md")):
if ".git" in path.parts:
continue
name = path.relative_to(ROOT).with_suffix("").as_posix()
if name in NOT_WIKI_PAGES:
continue
title = ""
text = path.read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if match:
for line in match.group(1).splitlines():
if line.startswith("title:"):
title = line.split(":", 1)[1].strip()
break
found.append((name, title))
return found


def fetch(path: str) -> tuple[int, str]:
try:
with OPENER.open(f"{WIKI_URL}/{path}", timeout=30) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, ""
except (urllib.error.URLError, OSError) as exc:
sys.exit(
f"Cannot reach {WIKI_URL} ({exc}).\n"
f"Is the stack up? docker compose up -d"
)


def main() -> int:
expected = pages()
if not expected:
sys.exit("No wiki pages found in the repository.")

failures: list[str] = []

for path, title in expected:
status, body = fetch(path)

if status in (301, 302, 303, 307, 308):
failures.append(
f"/{path}: HTTP {status} — redirected away, guests cannot read it"
)
continue

if status != 200:
failures.append(f"/{path}: HTTP {status}")
continue

if any(marker in body for marker in LOGIN_MARKERS):
failures.append(f"/{path}: served a login screen — guests cannot read it")
continue

if title and title not in body and html.escape(title) not in body:
failures.append(f"/{path}: served 200 but the title {title!r} is missing")

print(f"Checked {len(expected)} pages against {WIKI_URL}.")

if failures:
print(f"\n{len(failures)} problem(s):\n")
for failure in failures:
print(f" FAIL {failure}")
print("\nThe Wiki.js stack is not serving this repository anonymously.")
return 1

print("OK — every page is served, and no page asks for a login.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading