e1810facd6
tests / pytest (push) Successful in 7s
Every auto-published page, in all three pipelines, carried a source link to
`localhost:3005`. The template was never at fault — it links `{{{source}}}`
verbatim; the bad URL was baked into each page's wikitext by the publisher.
Root cause was one variable serving two incompatible purposes. `URL_TO_GITEA`
has to be an address the runner container can clone from, and since the runner
uses host networking that is `http://localhost:3005`. All three workflows then
composed `GITEA_REPO_URL` — the base of the public banner link, written into
the wiki for anyone to read — out of that same loopback value.
Adds a second org variable, `PUBLIC_URL_TO_GITEA`, for the public base
(`https://git.novoyuuparosk.org`), and points the banner at it. Cloning keeps
using `URL_TO_GITEA`. The two are now documented as deliberately separate,
because collapsing them looks like tidying up.
`lib/wiki.public_source_base()` validates the banner base rather than trusting
it: unset, relative, loopback, mDNS (`*.local`) or private-range values raise
with the variable to set. A misconfiguration that would publish an unreachable
link now stops the run instead. Note the failure mode this creates on purpose:
until `PUBLIC_URL_TO_GITEA` exists, publishing aborts rather than proceeding
with a wrong URL.
Also builds the source path with `rel.as_posix()`, so the separator in a URL
does not depend on the OS the publisher runs on — it produced backslashes when
the renderer was exercised on Windows.
Prerequisites before this can publish:
1. Add the org variable `PUBLIC_URL_TO_GITEA` = https://git.novoyuuparosk.org
2. Flush the act_runner callee cache — `docker compose up -d --force-recreate
act_runner`. Per the cross-cutting decisions log, edits to `publish-*.yml`
keep executing the stale cached copy, and this change is in those files.
Pages already published still carry the old URL in their stored wikitext. They
are only corrected by re-publishing each pipeline in full.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
5.3 KiB
Python
167 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Tech blog pipeline — publishes .md files to MediaWiki."""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
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,
|
|
public_source_base,
|
|
)
|
|
|
|
|
|
def validate(fm: dict, path: Path) -> None:
|
|
errors = []
|
|
|
|
val = fm.get("title")
|
|
if not val or not str(val).strip():
|
|
errors.append("missing or empty 'title'")
|
|
|
|
if not isinstance(fm.get("wiki", {}).get("publish"), bool):
|
|
errors.append("'wiki.publish' must be a boolean (true/false), not a string")
|
|
|
|
if "date" in fm and fm["date"]:
|
|
try:
|
|
datetime.strptime(str(fm["date"]), "%Y-%m-%d")
|
|
except ValueError:
|
|
errors.append("'date' must be YYYY-MM-DD")
|
|
|
|
if "tags" in fm and not isinstance(fm["tags"], list):
|
|
errors.append("'tags' must be a list")
|
|
|
|
if errors:
|
|
raise ValueError(f"{path}: " + "; ".join(errors))
|
|
|
|
|
|
def build_wikitext(fm: dict, body_wikitext: str, source_url: str, source_ref: str) -> str:
|
|
banner = f"{{{{Auto-generated|source={source_url}|commit={source_ref}}}}}"
|
|
cat_parts = ["[[Category:Tech blog]]"]
|
|
date_val = fm.get("date")
|
|
# Fall back to the action's execution year when no date is declared, so
|
|
# every post still lands in a Blog:<year> category.
|
|
year = str(date_val)[:4] if date_val else str(datetime.now().year)
|
|
cat_parts.append(f"[[Category:Blog:{year}]]")
|
|
for tag in (fm.get("tags") or []):
|
|
cat_parts.append(f"[[Category:{tag}]]")
|
|
categories = "\n".join(cat_parts) + "\n"
|
|
return f"{banner}\n\n{body_wikitext}{categories}"
|
|
|
|
|
|
def publish_one(abs_path: Path, source_dir: Path, post, site: mwclient.Site, source_ref: str, gitea_repo_url: str) -> str:
|
|
"""Returns 'published', 'noop', or 'skipped-manual'."""
|
|
rel = abs_path.relative_to(source_dir)
|
|
fm = post.metadata
|
|
title = str(fm["title"])
|
|
|
|
body_wikitext = markdown_to_wikitext(post.content)
|
|
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]
|
|
existing = page.text()
|
|
|
|
if existing and not existing.startswith(AUTO_BANNER_PREFIX):
|
|
return "skipped-manual"
|
|
|
|
if existing == page_content:
|
|
return "noop"
|
|
|
|
ref_short = source_ref[:8] if source_ref else "unknown"
|
|
page.save(page_content, summary=f"Auto-published from {ref_short} (tech pipeline)")
|
|
return "published"
|
|
|
|
|
|
def load_and_validate(files: list[Path], source_dir: Path) -> dict[Path, object]:
|
|
posts = {}
|
|
titles: dict[str, Path] = {}
|
|
|
|
for path in files:
|
|
if not path.exists():
|
|
print(f" warn: {path} not found, skipping", file=sys.stderr)
|
|
continue
|
|
|
|
post = frontmatter.load(str(path))
|
|
|
|
wiki = post.metadata.get("wiki")
|
|
if not isinstance(wiki, dict) or not wiki.get("publish"):
|
|
continue
|
|
|
|
validate(post.metadata, path)
|
|
|
|
title = str(post.metadata["title"])
|
|
if title in titles:
|
|
raise ValueError(f"Duplicate wiki title '{title}': {path} and {titles[title]}")
|
|
titles[title] = path
|
|
posts[path] = post
|
|
|
|
return posts
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Publish tech blog .md files to MediaWiki.")
|
|
parser.add_argument("--source-dir", required=True, type=Path)
|
|
parser.add_argument("--files", nargs="*", default=[], help="Relative paths within source-dir")
|
|
parser.add_argument("--all", action="store_true", help="Publish all publishable files")
|
|
args = parser.parse_args()
|
|
|
|
source_dir = args.source_dir.resolve()
|
|
source_ref = os.environ.get("SOURCE_REF", "")
|
|
gitea_repo_url = public_source_base()
|
|
|
|
if args.all:
|
|
candidates = list(source_dir.rglob("*.md"))
|
|
elif args.files:
|
|
candidates = [source_dir / f for f in args.files]
|
|
else:
|
|
print("Nothing to do: pass --files or --all.")
|
|
return
|
|
|
|
files = [
|
|
p for p in candidates
|
|
if p.exists()
|
|
and p.suffix == ".md"
|
|
and p.name != "README.md"
|
|
]
|
|
|
|
if not files:
|
|
print("No publishable candidates after filtering.")
|
|
return
|
|
|
|
print(f"Validating {len(files)} file(s)...")
|
|
posts = load_and_validate(files, source_dir)
|
|
print("Validation passed.")
|
|
|
|
site = connect_wiki()
|
|
|
|
counts = {"published": 0, "noop": 0, "skipped-manual": 0}
|
|
for abs_path, post in posts.items():
|
|
rel = abs_path.relative_to(source_dir)
|
|
outcome = publish_one(abs_path, source_dir, post, site, source_ref, gitea_repo_url)
|
|
counts[outcome] += 1
|
|
if outcome == "skipped-manual":
|
|
print(f" [skipped-manual] {rel} ← page exists without auto-gen banner; delete or add banner to hand over to bot", file=sys.stderr)
|
|
else:
|
|
print(f" [{outcome}] {rel}")
|
|
|
|
print(
|
|
f"\nDone: {counts['published']} published, {counts['noop']} unchanged, "
|
|
f"{counts['skipped-manual']} skipped (existing manual pages)."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|