Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a278870d | |||
| 736f40f953 | |||
| 73a8b5834a | |||
| b2714ab5af | |||
| bf96cd10e7 | |||
| bb57f319c5 | |||
| e838d3b105 | |||
| 217d633853 | |||
| 3d7af25aed | |||
| 47e5127299 | |||
| 2103a423fe |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# claude local settings
|
||||
.claude/
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 N−1 `===` 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,8 +41,9 @@ 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]
|
||||
# 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}]]")
|
||||
|
||||
Reference in New Issue
Block a user