Merge branch 'automation/public-source-url': split public banner URL from clone URL
tests / pytest (push) Successful in 5s
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:
@@ -34,7 +34,7 @@ jobs:
|
|||||||
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
||||||
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
||||||
SOURCE_REF: ${{ inputs.SOURCE_REF }}
|
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: |
|
run: |
|
||||||
python auto-wiki/pipelines/ses/publish.py \
|
python auto-wiki/pipelines/ses/publish.py \
|
||||||
--source-dir ses-light-novel \
|
--source-dir ses-light-novel \
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
||||||
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
||||||
SOURCE_REF: ${{ inputs.SOURCE_REF }}
|
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 }}
|
MODE: ${{ steps.diff.outputs.mode }}
|
||||||
FILES: ${{ steps.diff.outputs.files }}
|
FILES: ${{ steps.diff.outputs.files }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
WIKI_BOT_USER: ${{ secrets.WIKI_BOT_USER }}
|
||||||
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
WIKI_BOT_PASSWORD: ${{ secrets.WIKI_BOT_PASSWORD }}
|
||||||
SOURCE_REF: ${{ inputs.SOURCE_REF }}
|
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: |
|
run: |
|
||||||
python auto-wiki/pipelines/tech/publish.py \
|
python auto-wiki/pipelines/tech/publish.py \
|
||||||
--source-dir tech-blogs \
|
--source-dir tech-blogs \
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ Secrets and variables are scoped to the `novoyuuparosk-wiki` org, inherited by a
|
|||||||
**Variables:**
|
**Variables:**
|
||||||
- `WIKI_BASE_URL` = `https://wiki.novoyuuparosk.org`
|
- `WIKI_BASE_URL` = `https://wiki.novoyuuparosk.org`
|
||||||
- `WIKI_API_URL` = `https://wiki.novoyuuparosk.org/api.php`
|
- `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
|
## 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 |
|
| MediaWiki API path | `api.php` (classic action API) | 2026-06-09 |
|
||||||
| Branch naming (this repo) | `automation/<pipeline>` for pipeline-development branches | 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 |
|
| 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 |
|
| 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.
|
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_BASE_URL`
|
||||||
- [x] `WIKI_API_URL`
|
- [x] `WIKI_API_URL`
|
||||||
- [x] `URL_TO_GITEA`
|
- [x] `URL_TO_GITEA`
|
||||||
|
- [ ] `PUBLIC_URL_TO_GITEA` = `https://git.novoyuuparosk.org` — **required**; the publishers abort without it
|
||||||
|
|
||||||
With Pi access:
|
With Pi access:
|
||||||
|
|
||||||
|
|||||||
+58
@@ -1,6 +1,7 @@
|
|||||||
"""Shared utilities for novoyuuparosk-auto-wiki pipelines."""
|
"""Shared utilities for novoyuuparosk-auto-wiki pipelines."""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -199,6 +200,63 @@ def markdown_to_wikitext(body: str) -> str:
|
|||||||
return expand_columns(result.stdout)
|
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:
|
def connect_wiki() -> mwclient.Site:
|
||||||
api_url = os.environ["WIKI_API_URL"]
|
api_url = os.environ["WIKI_API_URL"]
|
||||||
parsed = urlparse(api_url)
|
parsed = urlparse(api_url)
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ Triggers:
|
|||||||
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
||||||
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
|
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
|
||||||
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
|
| `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 |
|
| `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 |
|
| `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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import frontmatter
|
|||||||
import mwclient
|
import mwclient
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
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:
|
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)
|
page_type = get_page_type(fm, abs_path, source_dir)
|
||||||
|
|
||||||
body_wikitext = markdown_to_wikitext(post.content)
|
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_content = build_wikitext(fm, body_wikitext, source_url, source_ref, page_type)
|
||||||
|
|
||||||
page = site.pages[title]
|
page = site.pages[title]
|
||||||
@@ -111,7 +116,7 @@ def main() -> None:
|
|||||||
|
|
||||||
source_dir = args.source_dir.resolve()
|
source_dir = args.source_dir.resolve()
|
||||||
source_ref = os.environ.get("SOURCE_REF", "")
|
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:
|
if args.all:
|
||||||
candidates = list(source_dir.rglob("*.md"))
|
candidates = list(source_dir.rglob("*.md"))
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ Triggers:
|
|||||||
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
||||||
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login (`Mikkeli@giteaBot`) |
|
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login (`Mikkeli@giteaBot`) |
|
||||||
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
|
| `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 |
|
| `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 |
|
| `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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from lib.wiki import (
|
|||||||
connect_wiki,
|
connect_wiki,
|
||||||
extract_md_link_targets,
|
extract_md_link_targets,
|
||||||
markdown_to_wikitext,
|
markdown_to_wikitext,
|
||||||
|
public_source_base,
|
||||||
resolve_md_links,
|
resolve_md_links,
|
||||||
strip_first_h1,
|
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 = markdown_to_wikitext(strip_first_h1(post.content))
|
||||||
body_wikitext = resolve_md_links(body_wikitext, abs_path, title_index)
|
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
|
lrc_filename = f"{title}.lrc" if fm.get("lrc") else None
|
||||||
return build_wikitext(fm, body_wikitext, source_url, source_ref, lrc_filename)
|
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_dir = args.source_dir.resolve()
|
||||||
source_ref = os.environ.get("SOURCE_REF", "")
|
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
|
# The repository is always scanned in full, whatever gets published: link
|
||||||
# resolution and title-collision checks must see every publishable file, or
|
# resolution and title-collision checks must see every publishable file, or
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ Triggers:
|
|||||||
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
| `WIKI_BASE_URL` | variable | Gitea (cross-cutting) | Wiki base URL |
|
||||||
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
|
| `WIKI_BOT_USER` | secret | Gitea (cross-cutting) | Bot login |
|
||||||
| `WIKI_BOT_PASSWORD` | secret | Gitea (cross-cutting) | BotPasswords value |
|
| `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 |
|
| `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 |
|
| `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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import frontmatter
|
|||||||
import mwclient
|
import mwclient
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
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:
|
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"])
|
title = str(fm["title"])
|
||||||
|
|
||||||
body_wikitext = markdown_to_wikitext(post.content)
|
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_content = build_wikitext(fm, body_wikitext, source_url, source_ref)
|
||||||
|
|
||||||
page = site.pages[title]
|
page = site.pages[title]
|
||||||
@@ -110,7 +115,7 @@ def main() -> None:
|
|||||||
|
|
||||||
source_dir = args.source_dir.resolve()
|
source_dir = args.source_dir.resolve()
|
||||||
source_ref = os.environ.get("SOURCE_REF", "")
|
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:
|
if args.all:
|
||||||
candidates = list(source_dir.rglob("*.md"))
|
candidates = list(source_dir.rglob("*.md"))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from lib.wiki import (
|
|||||||
expand_columns,
|
expand_columns,
|
||||||
extract_md_link_targets,
|
extract_md_link_targets,
|
||||||
markdown_to_wikitext,
|
markdown_to_wikitext,
|
||||||
|
public_source_base,
|
||||||
resolve_md_links,
|
resolve_md_links,
|
||||||
strip_first_h1,
|
strip_first_h1,
|
||||||
)
|
)
|
||||||
@@ -214,3 +215,72 @@ class TestPandocContract:
|
|||||||
wikitext = markdown_to_wikitext("```columns\n**A**\nl1\n===\n**B**\nl2\n```")
|
wikitext = markdown_to_wikitext("```columns\n**A**\nl1\n===\n**B**\nl2\n```")
|
||||||
assert wikitext.count("<poem>") == 2
|
assert wikitext.count("<poem>") == 2
|
||||||
assert "'''A'''" in wikitext
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user