11 Commits

Author SHA1 Message Date
mikkeli 34a278870d feat: side-by-side columns shorthand (```columns fence)
Authors write a ```columns fenced block (columns separated by a line of
===); Pandoc passes the body through verbatim as <pre class="columns">,
and a new post-Pandoc transform in lib.wiki.expand_columns expands it
into a flex <div> of <poem> columns. Runs entirely Pi-side before the
MediaWiki API write — no wiki template or PHP extension required.

The transform lives in shared lib/wiki.py (called from markdown_to_wikitext),
so it is universal across all pipelines. Stdlib only; no new deps.

Docs: SCHEMA.md author contract + songs/root decision logs.
Also ignore __pycache__/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:02:24 +09:00
mikkeli 736f40f953 Update README.md 2026-06-11 04:08:05 +00:00
wiki-runner image bot 73a8b5834a build: pin runner image to b2714ab 2026-06-11 04:07:09 +00:00
mikkeli b2714ab5af feat(ci): auto-pin publish workflows to freshly built runner image
/ build (push) Successful in 59s
/ pin (push) Successful in 10s
build-image.yml now pushes a moving :latest tag alongside :<short-sha>,
and a follow-up pin job (running in :latest) rewrites the image pin in all
three publish-*.yml to the new sha and commits it back to master with
FAPAT. Publish workflows keep immutable sha pins, kept current with no
manual bump. No rebuild loop: the pin commit only touches workflow_call
files. Root README image-flow section updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:05:43 +09:00
mikkeli bf96cd10e7 fix(tech): default Blog year category to execution year
Dateless posts silently lost their Blog:<year> category. build_wikitext
now falls back to datetime.now().year when no date is declared, so every
published post lands in a year category. Docs corrected accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:05:29 +09:00
mikkeli bb57f319c5 fix(ci): authenticate source fetch; repo is private again
/ build (push) Successful in 1m20s
Fetch uses the job's per-run auto-token (repo-scoped, nothing stored);
push moves to the PKGRW_PAT user secret, separating CI credentials
from the host's docker login token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:17:43 +09:00
mikkeli e838d3b105 build: bump runner image to CI-built 217d633
First image produced by the kaniko build workflow; replaces the
manually pushed v0 bootstrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:32:27 +09:00
mikkeli 217d633853 fix(ci): add /tmp to kaniko-act (r2)
/ build (push) Failing after 1s
kaniko's scratch image has no /tmp; the build script and act's
RUNNER_TEMP both assume it exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:59 +09:00
mikkeli 3d7af25aed fix(ci): wrap kaniko in act-compatible image
/ build (push) Failing after 1s
act starts job containers with entrypoint /bin/sleep, which kaniko's
scratch-based image lacks (busybox lives under /busybox). kaniko-act
adds the single missing symlink; bootstrap build is manual, documented
in the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:18:17 +09:00
mikkeli 47e5127299 feat(ci): build and push runner image with kaniko
/ build (push) Failing after 12s
Daemonless image build inside the job container itself
(kaniko :debug). Source fetched via Gitea archive API, pushed to the
Gitea registry tagged with the short commit SHA. Triggered by changes
to Dockerfile, any pipeline requirements.txt, or this workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:13:11 +09:00
mikkeli 2103a423fe build: pin job container image to Gitea registry (v0)
Workflows now pull pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner:v0
instead of relying on the host dockerd image cache. Registry is the
authority; act_runner pull_image flipped to true accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:27:41 +09:00
13 changed files with 193 additions and 23 deletions
+99
View File
@@ -0,0 +1,99 @@
on:
push:
branches: [master]
paths:
- Dockerfile
- "pipelines/**/requirements.txt"
- .gitea/workflows/build-image.yml
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
container:
# Daemonless image builder: kaniko :debug plus the /bin/sleep symlink
# act needs for job-container PID 1 (see ci/Dockerfile.kaniko-act).
# No docker socket, no privileges.
image: pi5-16.local:3005/mikkeli/kaniko-act:v1.23.2-r2
steps:
- name: Fetch source, build, push
# kaniko image has no bash — busybox sh only
shell: sh
env:
# package r/w PAT (user-level secret) — registry push only
PKG_TOKEN: ${{ secrets.PKGRW_PAT }}
# per-run auto-token, read access to this repo only — source fetch
JOB_TOKEN: ${{ github.token }}
URL_TO_GITEA: ${{ vars.URL_TO_GITEA }}
run: |
set -eu
HOST="${URL_TO_GITEA#http://}"
SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7)
IMAGE="${HOST}/mikkeli/novoyuuparosk-wiki-runner"
# Source via Gitea archive API (no git/node in this image; repo is
# private — authenticate with the job's own per-run token)
wget -qO /tmp/src.tar.gz \
"http://mikkeli:${JOB_TOKEN}@${HOST}/api/v1/repos/mikkeli/novoyuuparosk-auto-wiki/archive/${GITHUB_SHA}.tar.gz"
mkdir -p /tmp/src
tar -xzf /tmp/src.tar.gz -C /tmp/src --strip-components=1
# Registry auth for the push
AUTH=$(printf '%s' "mikkeli:${PKG_TOKEN}" | base64 | tr -d '\n')
printf '{"auths":{"%s":{"auth":"%s"}}}' "${HOST}" "${AUTH}" \
> /kaniko/.docker/config.json
# --insecure: push target is the plain-HTTP Gitea registry.
# Base image pull (docker.io) stays HTTPS — no --insecure-pull.
# :latest is a convenience tag for internal CI (the pin job below
# runs in it). Publish workflows stay pinned to the immutable sha.
/kaniko/executor \
--context dir:///tmp/src \
--dockerfile /tmp/src/Dockerfile \
--destination "${IMAGE}:${SHORT_SHA}" \
--destination "${IMAGE}:latest" \
--insecure
echo "Pushed ${IMAGE}:${SHORT_SHA} and ${IMAGE}:latest"
pin:
needs: build
runs-on: ubuntu-latest
container:
# Freshly built runner image: git + GNU sed baked in, and it has no
# non-shell ENTRYPOINT, so it works as a job container as-is (unlike the
# kaniko image above). Pulls :latest that the build job just pushed.
image: pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner:latest
steps:
- name: Repoint publish workflows at the new image tag
env:
# Full-access PAT — needs contents:write to push the pin commit.
# github.token is read-only here; PKGRW_PAT is registry-only.
FAPAT: ${{ secrets.FAPAT }}
URL_TO_GITEA: ${{ vars.URL_TO_GITEA }}
run: |
set -eu
HOST="${URL_TO_GITEA#http://}"
SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7)
git clone "http://mikkeli:${FAPAT}@${HOST}/mikkeli/novoyuuparosk-auto-wiki" repo
cd repo
for f in .gitea/workflows/publish-songs.yml \
.gitea/workflows/publish-ses.yml \
.gitea/workflows/publish-tech.yml; do
sed -i -E "s#(novoyuuparosk-wiki-runner:)[A-Za-z0-9._-]+#\1${SHORT_SHA}#" "$f"
done
if git diff --quiet; then
echo "Pins already at ${SHORT_SHA}; nothing to commit."
exit 0
fi
git config user.email "actions@novoyuuparosk.org"
git config user.name "wiki-runner image bot"
git commit -am "build: pin runner image to ${SHORT_SHA}"
# Pin commit only touches publish-*.yml (on: workflow_call) — triggers
# nothing, so no rebuild loop. Fails loudly if master moved meanwhile.
git push origin HEAD:master
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
publish:
runs-on: ubuntu-latest
container:
image: novoyuuparosk-wiki-runner:latest
image: pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner:b2714ab
steps:
- name: Checkout ses-light-novel
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
publish:
runs-on: ubuntu-latest
container:
image: novoyuuparosk-wiki-runner:latest
image: pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner:b2714ab
steps:
- name: Checkout ncmr-songs
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
publish:
runs-on: ubuntu-latest
container:
image: novoyuuparosk-wiki-runner:latest
image: pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner:b2714ab
steps:
- name: Checkout tech-blogs
+3
View File
@@ -1,2 +1,5 @@
# claude local settings
.claude/
# python
__pycache__/
+5 -7
View File
@@ -8,7 +8,7 @@ CI/CD pipelines that auto-apply commits to https://wiki.novoyuuparosk.org from u
|---|---|---|---|
| [`pipelines/songs/`](pipelines/songs/) | `mikkeli/ncmr-songs` | Song lyric pages | v1 live |
| [`pipelines/ses/`](pipelines/ses/) | `mikkeli/ses-light-novel` | SES light novel pages | v1 in development |
| [`pipelines/tech/`](pipelines/tech/) | `mikkeli/tech-blogs` | Tech blog posts | v1 ready (source repo not yet initialised) |
| [`pipelines/tech/`](pipelines/tech/) | `mikkeli/tech-blogs` | Tech blog posts | v1 live |
Per-pipeline READMEs cover everything specific to that pipeline (source schema, renderer, runtime, decisions). This root README covers only what's cross-cutting.
@@ -49,14 +49,11 @@ Container network mode: `host` — required so job containers can reach `localho
### Job container image
All pipelines share a single pre-built Docker image: `novoyuuparosk-wiki-runner:latest`. The `Dockerfile` is at the repo root. It bakes in system deps (git, pandoc, ca-certificates) and all pipeline Python packages so job containers start instantly with no install steps.
All pipelines share a single pre-built Docker image, served from the Gitea registry at `pi5-16.local:3005/mikkeli/novoyuuparosk-wiki-runner`. The `Dockerfile` is at the repo root. It bakes in system deps (git, pandoc, ca-certificates) and all pipeline Python packages so job containers start instantly with no install steps.
The image is built manually on the Pi and stored in the local Docker daemon (`pull_image: false` in act_runner config). Rebuild after any change to the `Dockerfile` or a pipeline `requirements.txt`:
The image builds automatically via [`.gitea/workflows/build-image.yml`](.gitea/workflows/build-image.yml), which triggers on pushes that touch the `Dockerfile`, any pipeline `requirements.txt`, or that workflow itself. It uses kaniko (daemonless, unprivileged) to build and push two tags: an immutable `:<short-sha>` and a moving `:latest`.
```bash
docker build -t novoyuuparosk-wiki-runner:latest \
/home/mikkeli/dev/novoyuuparosk-auto-wiki
```
A follow-up `pin` job then rewrites the `image:` pin in each `publish-*.yml` to the new `:<short-sha>` and commits it back to `master` (using `FAPAT` for contents write). The publish workflows therefore always reference an immutable tag, kept current automatically — no manual bump. The pin commit only touches `workflow_call` files, so it triggers no further runs.
## Gitea Actions setup (cross-cutting)
@@ -89,6 +86,7 @@ Secrets and variables are scoped to user `mikkeli` (no orgs on this instance), i
| 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 |
| 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.
+14
View File
@@ -0,0 +1,14 @@
# Kaniko executor adapted for Gitea act_runner job containers.
#
# act starts job containers with entrypoint /bin/sleep and sets
# RUNNER_TEMP=/tmp; kaniko's scratch-based image has neither /bin/sleep
# (busybox lives under /busybox; /bin/sh is already symlinked) nor /tmp.
# This wrapper adds exactly those two. Nothing else changes.
#
# Bootstrap: the first build of this image is done manually on the host
# (docker build -f ci/Dockerfile.kaniko-act -t <registry>/mikkeli/kaniko-act:<ver> .)
# because no builder image exists yet. Later version bumps can be built by
# the build-image workflow itself, using the previous kaniko-act.
FROM gcr.io/kaniko-project/executor:v1.23.2-debug
SHELL ["/busybox/sh", "-c"]
RUN ln -sf /busybox/sleep /bin/sleep && mkdir -p -m 1777 /tmp
+29 -1
View File
@@ -1,6 +1,8 @@
"""Shared utilities for novoyuuparosk-auto-wiki pipelines."""
import html
import os
import re
import subprocess
from urllib.parse import urlparse
@@ -8,6 +10,11 @@ import mwclient
AUTO_BANNER_PREFIX = "{{Auto-generated"
# A ```columns fenced block is passed through Pandoc verbatim as
# <pre class="columns">…</pre>; columns within it are separated by a line of ===.
_COLUMNS_BLOCK_RE = re.compile(r'<pre class="columns">(.*?)</pre>', re.DOTALL)
_COLUMN_SEP_RE = re.compile(r"^\s*===\s*$", re.MULTILINE)
def strip_first_h1(text: str) -> str:
"""Remove the first '# Heading' line and any immediately following blank line."""
@@ -21,6 +28,27 @@ def strip_first_h1(text: str) -> str:
return "\n".join(lines)
def expand_columns(wikitext: str) -> str:
"""Expand ```columns fenced blocks into a flex row of <poem> columns.
Authors write a fenced code block tagged ``columns``; Pandoc passes its body
through verbatim as ``<pre class="columns">…</pre>`` (line breaks and blank
lines preserved, inline markup entity-escaped). Columns within the block are
separated by a line containing only ``===``. Each column is wrapped in
<poem> so its line breaks survive MediaWiki parsing, and content is
HTML-unescaped so inline markup written in the fence (e.g. <b>…</b>) renders
rather than appearing as literal text.
"""
def render(match: re.Match) -> str:
body = html.unescape(match.group(1))
columns = _COLUMN_SEP_RE.split(body)
poems = "".join("<poem>\n" + col.strip("\n") + "\n</poem>" for col in columns)
return '<div style="display:flex; gap:3em; align-items:flex-start">' + poems + "</div>"
return _COLUMNS_BLOCK_RE.sub(render, wikitext)
def markdown_to_wikitext(body: str) -> str:
result = subprocess.run(
["pandoc", "-f", "markdown", "-t", "mediawiki"],
@@ -30,7 +58,7 @@ def markdown_to_wikitext(body: str) -> str:
)
if result.returncode != 0:
raise RuntimeError(f"pandoc failed: {result.stderr.strip()}")
return result.stdout
return expand_columns(result.stdout)
def connect_wiki() -> mwclient.Site:
+1
View File
@@ -89,6 +89,7 @@ The pipeline always runs `--all`: every file with `wiki.publish: true` (not unde
| Publish mode | Always `--all`; no diff detection — small repo, simpler than fragile git-diff gating | 2026-06-09 |
| Manual-page protection | Bot skips pages without the `{{Auto-generated` banner to avoid overwriting hand-written content | 2026-06-09 |
| `workflow_call` abandoned | Cross-repo `workflow_call` fails — run token scoped to triggering repo; cannot clone private callee | 2026-06-09 |
| Side-by-side columns | Authored as a ` ```columns ` fenced block (separator `===`); Pandoc emits `<pre class="columns">`, expanded post-Pandoc into a flex `<div>` of `<poem>` columns. No wiki template or extension — all Pi-side before the API write. See [SCHEMA.md](SCHEMA.md#columns-side-by-side-shorthand) | 2026-06-14 |
## Status
+29 -3
View File
@@ -122,20 +122,46 @@ The siblings list is a bill of materials — declaring a sibling has no effect u
## Body
Plain markdown. The renderer applies two transformations before and after Pandoc:
Plain markdown. The renderer applies these transformations before and after Pandoc:
1. **Pre-Pandoc**: strip the first-line `# Heading` if present.
2. **Post-Pandoc**: substitute `TAG` placeholders declared in `wiki.siblings`.
2. **Post-Pandoc**: expand `columns` fenced blocks into side-by-side wikitext (see below).
3. **Post-Pandoc**: substitute `TAG` placeholders declared in `wiki.siblings`.
Do not embed raw wikitext-specific syntax (`{{Template}}`, raw `[[Wikilink]]` not declared via `siblings`, etc.) in the body unless you intend the literal output. Use markdown idioms; the renderer adds the metadata-derived bits (banner, LRC link, categories, sibling resolution) around Pandoc's output.
### Columns (side-by-side) shorthand
For parallel content — e.g. an original and its translation — write a fenced code block tagged `columns` and separate the columns with a line containing only `===`:
````markdown
```columns
<b>原題</b>
一行目
二行目
===
<b>Title</b>
first line
second line
```
````
The renderer turns this into a flexbox row of `<poem>` columns (one per `===`-delimited section). Notes:
- **Line breaks and blank lines are preserved verbatim** — that's the point of using a fenced block; Pandoc passes the body through untouched, and each column is wrapped in `<poem>` so MediaWiki keeps the line breaks.
- **N columns**: use N1 `===` separators. Two is the common case (original / translation).
- **Inline markup is allowed**: HTML such as `<b>…</b>` or `<br>` written inside the block is HTML-unescaped on the way out, so it renders rather than showing as literal text.
- This is a shared transform (`lib/wiki.py`), so it works for any pipeline, not just songs. Column width/gap styling currently lives in that transform.
## Renderer behaviour
For each `.md` with `wiki.publish: true` and not under `wip/`:
1. Parse and validate frontmatter.
2. Strip the leading `# Heading` from the body if present.
3. Pipe the body through `pandoc -f markdown -t mediawiki`.
3. Pipe the body through `pandoc -f markdown -t mediawiki`, then expand any `columns` fenced blocks into side-by-side `<poem>` columns.
4. Substitute `TAG` placeholders with resolved `[[Page|Display]]` wikilinks (or fallback text).
5. Prepend the auto-generated banner: `{{Auto-generated|source=<source URL>|commit=<sha>}}`.
6. If `lrc` is declared: upload the LRC file if its SHA1 has changed; append a `[[Media:…]]` link.
+2 -2
View File
@@ -10,7 +10,7 @@ Publishes tech blog posts from `mikkeli/tech-blogs` to `https://wiki.novoyuuparo
- YAML frontmatter parsing per [SCHEMA.md](SCHEMA.md)
- Pandoc-based markdown → wikitext body rendering (h1 elements preserved)
- Auto-generated banner (`{{Auto-generated|source=...|commit=<sha>}}`)
- Category injection: `[[Category:Tech blog]]` on every page; `[[Category:Blog:<year>]]` from `date` field; `[[Category:<tag>]]` per tag entry
- Category injection: `[[Category:Tech blog]]` on every page; `[[Category:Blog:<year>]]` from `date` field (falling back to the action's execution year); `[[Category:<tag>]]` per tag entry
- No title prefix — pages live at their bare title
- MediaWiki bot API write with idempotency — no-op if wiki content matches generated output
- Files without `wiki.publish: true` silently skipped
@@ -65,7 +65,7 @@ Shared code lives in `lib/wiki.py` (repo root).
|---|---|---|
| No title prefix | Tech posts live at bare titles — no namespace needed | 2026-06-10 |
| Fixed category | Every post gets `Category:Tech blog` | 2026-06-10 |
| Year category | `Category:Blog:<year>` from `date` field; omitted if no date | 2026-06-10 |
| Year category | `Category:Blog:<year>` from `date` field; falls back to the execution year if no date | 2026-06-10 |
| Tag categories | Each tag → `[[Category:<tag>]]` with no prefix | 2026-06-10 |
| WIP exclusion | None — `wiki.publish: false` is the only gate | 2026-06-10 |
| Publish mode | Always `--all` | 2026-06-10 |
+3 -3
View File
@@ -45,7 +45,7 @@ wiki:
#### `date` (ISO 8601 date)
Publication date in `YYYY-MM-DD` format. Drives `[[Category:Blog:<year>]]` injection. If omitted, no year category is added.
Publication date in `YYYY-MM-DD` format. Drives `[[Category:Blog:<year>]]` injection. If omitted, the year falls back to the time the publish action runs.
#### `tags` (list of strings)
@@ -69,7 +69,7 @@ For each `.md` with `wiki.publish: true`:
1. Parse and validate frontmatter.
2. Pipe the body through `pandoc -f markdown -t mediawiki`.
4. Prepend the auto-generated banner: `{{Auto-generated|source=<source URL>|commit=<sha>}}`.
5. Append category tags: `[[Category:Tech blog]]`; `[[Category:Blog:<year>]]` if `date` is set; `[[Category:<tag>]]` for each tag.
5. Append category tags: `[[Category:Tech blog]]`; `[[Category:Blog:<year>]]` (from `date`, or the action's execution year if `date` is absent); `[[Category:<tag>]]` for each tag.
6. Read the current wiki page content; if identical, skip the write (idempotency).
7. If the page exists without `{{Auto-generated`, skip with a warning (manual page protection).
8. Otherwise, write with an edit summary referencing the source commit.
@@ -80,7 +80,7 @@ For each `.md` with `wiki.publish: true`:
- Pandoc-based markdown → wikitext rendering (h1 elements preserved)
- Banner template injection
- `Category:Tech blog` on every published page
- `Category:Blog:<year>` from `date` field
- `Category:Blog:<year>` from `date` field, falling back to the execution year
- `Category:<tag>` per entry in `tags`
- Idempotent writes
- Manual-page protection
+4 -3
View File
@@ -41,9 +41,10 @@ def build_wikitext(fm: dict, body_wikitext: str, source_url: str, source_ref: st
banner = f"{{{{Auto-generated|source={source_url}|commit={source_ref}}}}}"
cat_parts = ["[[Category:Tech blog]]"]
date_val = fm.get("date")
if date_val:
year = str(date_val)[:4]
cat_parts.append(f"[[Category:Blog:{year}]]")
# 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"