Merge branch 'automation/public-source-url': split public banner URL from clone URL
tests / pytest (push) Successful in 5s

The publish workflows composed the {{Auto-generated}} banner's source link
from URL_TO_GITEA, the runner-internal clone address (http://localhost:3005),
so published pages cited a URL no reader could resolve. The banner URL now
comes from the PUBLIC_URL_TO_GITEA variable, validated by public_source_base()
in lib/wiki.py: unset, schemeless, loopback, *.local and private-range values
raise rather than reach the wiki. URL_TO_GITEA keeps its clone-address role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwzQyCzCCLriTgs7b9uxwY
This commit is contained in:
2026-09-09 16:59:50 +09:00
12 changed files with 166 additions and 18 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
SOURCE_REF: ${{ inputs.SOURCE_REF }}
GITEA_REPO_URL: ${{ vars.URL_TO_GITEA }}/novoyuuparosk-wiki/ses-light-novel
GITEA_REPO_URL: ${{ vars.PUBLIC_URL_TO_GITEA }}/novoyuuparosk-wiki/ses-light-novel
run: |
python auto-wiki/pipelines/ses/publish.py \
--source-dir ses-light-novel \
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
SOURCE_REF: ${{ inputs.SOURCE_REF }}
GITEA_REPO_URL: ${{ vars.URL_TO_GITEA }}/novoyuuparosk-wiki/ncmr-songs
GITEA_REPO_URL: ${{ vars.PUBLIC_URL_TO_GITEA }}/novoyuuparosk-wiki/ncmr-songs
MODE: ${{ steps.diff.outputs.mode }}
FILES: ${{ steps.diff.outputs.files }}
run: |
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
SOURCE_REF: ${{ inputs.SOURCE_REF }}
GITEA_REPO_URL: ${{ vars.URL_TO_GITEA }}/novoyuuparosk-wiki/tech-blogs
GITEA_REPO_URL: ${{ vars.PUBLIC_URL_TO_GITEA }}/novoyuuparosk-wiki/tech-blogs
run: |
python auto-wiki/pipelines/tech/publish.py \
--source-dir tech-blogs \
+7 -1
View File
@@ -72,7 +72,10 @@ Secrets and variables are scoped to the `novoyuuparosk-wiki` org, inherited by a
**Variables:**
- `WIKI_BASE_URL` = `https://wiki.novoyuuparosk.org`
- `WIKI_API_URL` = `https://wiki.novoyuuparosk.org/api.php`
- `URL_TO_GITEA` = Gitea instance base URL (e.g. `http://localhost:3005`). Named with `URL_TO_` prefix — Gitea blocks variable names starting with `GITEA_` or `GITHUB_`.
- `URL_TO_GITEA` = **runner-internal** Gitea base URL, used only for cloning (e.g. `http://localhost:3005` — the runner uses host networking). Named with `URL_TO_` prefix — Gitea blocks variable names starting with `GITEA_` or `GITHUB_`.
- `PUBLIC_URL_TO_GITEA` = **public** Gitea base URL (`https://git.novoyuuparosk.org`), used only for the source link in each published page's banner.
These two must stay separate. `URL_TO_GITEA` is a loopback address, so composing the banner from it published every page — across all three pipelines — citing `localhost:3005`, which resolves for nobody reading the wiki. `lib/wiki.public_source_base()` now rejects a loopback, mDNS, or private-range value, so that misconfiguration fails the run instead of reaching a page.
## Branch naming
@@ -98,6 +101,8 @@ Secrets and variables are scoped to the `novoyuuparosk-wiki` org, inherited by a
| MediaWiki API path | `api.php` (classic action API) | 2026-06-09 |
| Branch naming (this repo) | `automation/<pipeline>` for pipeline-development branches | 2026-06-09 |
| Variable naming | `URL_TO_GITEA` not `GITEA_URL` — Gitea blocks `GITEA_`/`GITHUB_` prefixes | 2026-06-09 |
| **Clone URL ≠ banner URL** | Split into `URL_TO_GITEA` (runner-internal, for cloning) and `PUBLIC_URL_TO_GITEA` (public, for the page banner). One variable served both, and since host networking forces the clone address to `localhost:3005`, every auto-published page in every pipeline cited a link no reader could open. The banner value is validated by `public_source_base()` — loopback, mDNS and private-range addresses abort the run. Pages published before this carry the bad URL in their wikitext and need a full re-publish per pipeline to clear | 2026-09-09 |
| Source URL separators | `rel.as_posix()` in the banner URL, so the path separator never depends on the OS the publisher happens to run on | 2026-09-09 |
| Columns shorthand | Side-by-side columns authored as a ` ```columns ` fenced block, expanded post-Pandoc in shared `lib/wiki.py` (universal across pipelines). No wiki template or PHP extension — runs Pi-side before the API call | 2026-06-14 |
Per-pipeline decisions live in each pipeline's README.
@@ -113,6 +118,7 @@ Via the Gitea web UI logged in as `mikkeli` (an owner of `novoyuuparosk-wiki`):
- [x] `WIKI_BASE_URL`
- [x] `WIKI_API_URL`
- [x] `URL_TO_GITEA`
- [ ] `PUBLIC_URL_TO_GITEA` = `https://git.novoyuuparosk.org` — **required**; the publishers abort without it
With Pi access:
+58
View File
@@ -1,6 +1,7 @@
"""Shared utilities for novoyuuparosk-auto-wiki pipelines."""
import html
import ipaddress
import os
import re
import subprocess
@@ -199,6 +200,63 @@ def markdown_to_wikitext(body: str) -> str:
return expand_columns(result.stdout)
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "localhost.localdomain", "ip6-localhost"})
def public_source_base(env_var: str = "GITEA_REPO_URL") -> str:
"""The public base URL for the source links baked into published pages.
This URL ends up in the `{{Auto-generated}}` banner of every page, so it is
read by anyone on the internet — it is *not* the address the runner clones
from. Those two are different hosts here: the runner container uses host
networking and reaches Gitea on `localhost:3005`, while a reader needs the
public name. Composing the banner from the clone address published pages
citing `localhost:3005`, which is why this is validated rather than trusted.
Rejects an address that cannot possibly work for a reader — unset, missing a
scheme or host, loopback, mDNS (`*.local`), or a private IP range — so the
misconfiguration fails the run instead of being written to the wiki.
"""
raw = os.environ.get(env_var, "").strip().rstrip("/")
if not raw:
raise RuntimeError(
f"{env_var} is unset. It must be the *public* Gitea base URL plus the "
f"repository path (e.g. https://git.example.org/org/repo); the workflow "
f"composes it from the PUBLIC_URL_TO_GITEA Gitea variable."
)
parsed = urlparse(raw)
if not parsed.scheme or not parsed.netloc:
raise RuntimeError(
f"{env_var} is not an absolute URL: {raw!r}. Expected scheme and host, "
f"e.g. https://git.example.org/org/repo."
)
host = (parsed.hostname or "").lower()
reason = None
if host in _LOOPBACK_HOSTNAMES:
reason = "a loopback hostname"
elif host.endswith(".local"):
reason = "an mDNS name, resolvable only on the local network"
else:
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and (address.is_loopback or address.is_private):
reason = "a loopback or private-range IP address"
if reason:
raise RuntimeError(
f"{env_var} points at {reason}: {raw!r}. This URL is published in the "
f"page banner and must resolve for a reader off the network. Set the "
f"PUBLIC_URL_TO_GITEA Gitea variable to the public Gitea base URL — "
f"URL_TO_GITEA is the runner-internal clone address and is not usable here."
)
return raw
def connect_wiki() -> mwclient.Site:
api_url = os.environ["WIKI_API_URL"]
parsed = urlparse(api_url)
+3 -2
View File
@@ -48,10 +48,11 @@ Triggers:
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Gitea instance base URL for cloning |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Runner-internal Gitea base URL, for cloning only |
| `PUBLIC_URL_TO_GITEA` | variable | Gitea (cross-cutting) | Public Gitea base URL, for the banner source link only |
| `FAPAT` | secret | Gitea (cross-cutting) | Full-Access PAT for cloning private repos |
| `SOURCE_REF` | env (set in workflow) | `github.sha` | Pushed commit SHA, used in banner and edit summary |
| `GITEA_REPO_URL` | env (set in workflow) | composed from vars | Source-file URL base for the banner link |
| `GITEA_REPO_URL` | env (set in workflow) | `PUBLIC_URL_TO_GITEA` + repo path | Source-file URL base for the banner link. Validated by `public_source_base()`: a loopback, mDNS or private-range host aborts the run rather than publishing an unreachable link |
## Dependencies
+8 -3
View File
@@ -10,7 +10,12 @@ import frontmatter
import mwclient
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.wiki import AUTO_BANNER_PREFIX, connect_wiki, markdown_to_wikitext
from lib.wiki import (
AUTO_BANNER_PREFIX,
connect_wiki,
markdown_to_wikitext,
public_source_base,
)
def validate(fm: dict, path: Path) -> None:
@@ -59,7 +64,7 @@ def publish_one(abs_path: Path, source_dir: Path, post, site: mwclient.Site, sou
page_type = get_page_type(fm, abs_path, source_dir)
body_wikitext = markdown_to_wikitext(post.content)
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel}"
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel.as_posix()}"
page_content = build_wikitext(fm, body_wikitext, source_url, source_ref, page_type)
page = site.pages[title]
@@ -111,7 +116,7 @@ def main() -> None:
source_dir = args.source_dir.resolve()
source_ref = os.environ.get("SOURCE_REF", "")
gitea_repo_url = os.environ.get("GITEA_REPO_URL", "").rstrip("/")
gitea_repo_url = public_source_base()
if args.all:
candidates = list(source_dir.rglob("*.md"))
+3 -2
View File
@@ -53,10 +53,11 @@ Triggers:
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login (`Mikkeli@giteaBot`) |
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Gitea instance base URL for cloning |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Runner-internal Gitea base URL, for cloning only |
| `PUBLIC_URL_TO_GITEA` | variable | Gitea (cross-cutting) | Public Gitea base URL, for the banner source link only |
| `FAPAT` | secret | Gitea (cross-cutting) | Full-Access PAT for cloning private repos |
| `SOURCE_REF` | env (set in workflow) | `github.sha` | Pushed commit SHA, used in banner and edit summary |
| `GITEA_REPO_URL` | env (set in workflow) | composed from vars | Source-file URL base for the banner link |
| `GITEA_REPO_URL` | env (set in workflow) | `PUBLIC_URL_TO_GITEA` + repo path | Source-file URL base for the banner link. Validated by `public_source_base()`: a loopback, mDNS or private-range host aborts the run rather than publishing an unreachable link |
## Dependencies
+3 -2
View File
@@ -19,6 +19,7 @@ from lib.wiki import (
connect_wiki,
extract_md_link_targets,
markdown_to_wikitext,
public_source_base,
resolve_md_links,
strip_first_h1,
)
@@ -153,7 +154,7 @@ def render_page(abs_path: Path, source_dir: Path, post, source_ref: str, gitea_r
body_wikitext = markdown_to_wikitext(strip_first_h1(post.content))
body_wikitext = resolve_md_links(body_wikitext, abs_path, title_index)
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel}"
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel.as_posix()}"
lrc_filename = f"{title}.lrc" if fm.get("lrc") else None
return build_wikitext(fm, body_wikitext, source_url, source_ref, lrc_filename)
@@ -241,7 +242,7 @@ def main() -> None:
source_dir = args.source_dir.resolve()
source_ref = os.environ.get("SOURCE_REF", "")
gitea_repo_url = os.environ.get("GITEA_REPO_URL", "").rstrip("/")
gitea_repo_url = public_source_base()
# The repository is always scanned in full, whatever gets published: link
# resolution and title-collision checks must see every publishable file, or
+3 -2
View File
@@ -42,10 +42,11 @@ Triggers:
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Gitea instance base URL for cloning |
| `URL_TO_GITEA` | variable | Gitea (cross-cutting) | Runner-internal Gitea base URL, for cloning only |
| `PUBLIC_URL_TO_GITEA` | variable | Gitea (cross-cutting) | Public Gitea base URL, for the banner source link only |
| `FAPAT` | secret | Gitea (cross-cutting) | Full-Access PAT for cloning private repos |
| `SOURCE_REF` | env (set in workflow) | `github.sha` | Pushed commit SHA, used in banner and edit summary |
| `GITEA_REPO_URL` | env (set in workflow) | composed from vars | Source-file URL base for the banner link |
| `GITEA_REPO_URL` | env (set in workflow) | `PUBLIC_URL_TO_GITEA` + repo path | Source-file URL base for the banner link. Validated by `public_source_base()`: a loopback, mDNS or private-range host aborts the run rather than publishing an unreachable link |
## Dependencies
+8 -3
View File
@@ -11,7 +11,12 @@ import frontmatter
import mwclient
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.wiki import AUTO_BANNER_PREFIX, connect_wiki, markdown_to_wikitext
from lib.wiki import (
AUTO_BANNER_PREFIX,
connect_wiki,
markdown_to_wikitext,
public_source_base,
)
def validate(fm: dict, path: Path) -> None:
@@ -58,7 +63,7 @@ def publish_one(abs_path: Path, source_dir: Path, post, site: mwclient.Site, sou
title = str(fm["title"])
body_wikitext = markdown_to_wikitext(post.content)
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel}"
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel.as_posix()}"
page_content = build_wikitext(fm, body_wikitext, source_url, source_ref)
page = site.pages[title]
@@ -110,7 +115,7 @@ def main() -> None:
source_dir = args.source_dir.resolve()
source_ref = os.environ.get("SOURCE_REF", "")
gitea_repo_url = os.environ.get("GITEA_REPO_URL", "").rstrip("/")
gitea_repo_url = public_source_base()
if args.all:
candidates = list(source_dir.rglob("*.md"))
+70
View File
@@ -15,6 +15,7 @@ from lib.wiki import (
expand_columns,
extract_md_link_targets,
markdown_to_wikitext,
public_source_base,
resolve_md_links,
strip_first_h1,
)
@@ -214,3 +215,72 @@ class TestPandocContract:
wikitext = markdown_to_wikitext("```columns\n**A**\nl1\n===\n**B**\nl2\n```")
assert wikitext.count("<poem>") == 2
assert "'''A'''" in wikitext
class TestPublicSourceBase:
"""The banner URL is read by the public, so a runner-only address must fail.
Every one of these rejections was a page published with a link to
localhost:3005, which is what the whole guard exists to prevent.
"""
def test_accepts_a_public_https_url(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "https://git.novoyuuparosk.org/org/repo")
assert public_source_base() == "https://git.novoyuuparosk.org/org/repo"
def test_accepts_a_public_host_with_a_port(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://git.novoyuuparosk.org:1551/org/repo")
assert public_source_base() == "http://git.novoyuuparosk.org:1551/org/repo"
def test_strips_a_trailing_slash(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "https://git.example.org/org/repo/")
assert public_source_base() == "https://git.example.org/org/repo"
def test_strips_surrounding_whitespace(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", " https://git.example.org/org/repo ")
assert public_source_base() == "https://git.example.org/org/repo"
def test_unset_is_rejected(self, monkeypatch):
monkeypatch.delenv("GITEA_REPO_URL", raising=False)
with pytest.raises(RuntimeError, match="is unset"):
public_source_base()
def test_empty_is_rejected(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "")
with pytest.raises(RuntimeError, match="is unset"):
public_source_base()
def test_a_bare_path_is_rejected(self, monkeypatch):
# What an unset PUBLIC_URL_TO_GITEA leaves the workflow composing.
monkeypatch.setenv("GITEA_REPO_URL", "/novoyuuparosk-wiki/ncmr-songs")
with pytest.raises(RuntimeError, match="not an absolute URL"):
public_source_base()
def test_localhost_is_rejected(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
with pytest.raises(RuntimeError, match="loopback hostname"):
public_source_base()
def test_loopback_ip_is_rejected(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://127.0.0.1:3005/org/repo")
with pytest.raises(RuntimeError, match="loopback or private-range"):
public_source_base()
def test_private_range_ip_is_rejected(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://192.168.1.20:3005/org/repo")
with pytest.raises(RuntimeError, match="loopback or private-range"):
public_source_base()
def test_mdns_name_is_rejected(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://pi5-16.local:3005/org/repo")
with pytest.raises(RuntimeError, match="mDNS"):
public_source_base()
def test_rejection_names_the_variable_to_set(self, monkeypatch):
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
with pytest.raises(RuntimeError, match="PUBLIC_URL_TO_GITEA"):
public_source_base()
def test_reads_the_named_variable(self, monkeypatch):
monkeypatch.setenv("OTHER_BASE", "https://git.example.org/org/repo")
assert public_source_base("OTHER_BASE") == "https://git.example.org/org/repo"