Compare commits
9 Commits
df6372ac94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25737953d8 | |||
| b659386bd2 | |||
| c615a5fb92 | |||
| 7b9a70f4fe | |||
| c414984ba3 | |||
| 8bef637053 | |||
| 922aea9bc1 | |||
| 64abb39d0b | |||
| 4293e90a97 |
@@ -0,0 +1,10 @@
|
||||
# Keep LF on everything the Linux build/runtime executes — CRLF breaks the
|
||||
# shebang in shell scripts and is undesirable in the C/Python/Docker sources.
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.c text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.mpq binary
|
||||
@@ -0,0 +1,60 @@
|
||||
# CI for the packager itself: build the Docker image and smoke-test that it
|
||||
# produces a non-empty patch-Z.mpq from the test fixture.
|
||||
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# octowow.st is served under /git, but Gitea generates clone URLs without
|
||||
# it (ROOT_URL/proxy misconfig), so actions/checkout 404s. Clone manually
|
||||
# against the correct base. Remove once the server ROOT_URL is fixed.
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "https://gitea:${{ secrets.GITHUB_TOKEN }}@octowow.st/git/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "${{ github.ref }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t mpq-packager:ci .
|
||||
|
||||
# NOTE: act_runner runs these `docker` commands against the HOST daemon via
|
||||
# the mounted socket, so bind-mounting the job's files (-v "$PWD:/work")
|
||||
# doesn't work — the source path isn't on the host. Use `docker cp` to
|
||||
# stream files in/out of a created container instead. The GlueXML release
|
||||
# workflow uses this same pattern.
|
||||
- name: Package the fixture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid=$(docker create \
|
||||
-e INPUT_MANIFEST=mpq.yaml \
|
||||
-e INPUT_VERSION=v0.0.0-ci \
|
||||
-e INPUT_OUT_DIR=/work/dist \
|
||||
-e GITHUB_WORKSPACE=/work \
|
||||
mpq-packager:ci)
|
||||
docker cp test/. "$cid:/work"
|
||||
docker start -a "$cid"
|
||||
docker cp "$cid:/work/dist/patch-Z.mpq" ./patch-Z.mpq
|
||||
docker rm "$cid" >/dev/null
|
||||
|
||||
- name: Assert archive is valid and contains the expected file
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s patch-Z.mpq
|
||||
echo "size: $(stat -c%s patch-Z.mpq) bytes"
|
||||
# Read the archive back with StormLib (mpqpack --list/--cat) inside a
|
||||
# sleeping container we exec into.
|
||||
cid=$(docker run -d --entrypoint sleep mpq-packager:ci 120)
|
||||
docker cp patch-Z.mpq "$cid:/patch-Z.mpq"
|
||||
docker exec "$cid" mpqpack --list /patch-Z.mpq | tee list.txt
|
||||
grep -q 'Interface\\AddOns\\Fixture\\Hello.lua' list.txt
|
||||
docker exec "$cid" mpqpack --cat /patch-Z.mpq 'Interface\AddOns\Fixture\Hello.lua' \
|
||||
| grep -q 'VERSION = "v0.0.0-ci"'
|
||||
docker rm -f "$cid" >/dev/null
|
||||
echo "OK: archive valid, path + substitution confirmed"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
dist/
|
||||
test/dist/
|
||||
*.mpq
|
||||
*.o
|
||||
# local validation helpers (not needed in CI)
|
||||
test/stub_mpqpack.py
|
||||
test/mpqpack.cmd
|
||||
# runner secrets/state
|
||||
runner/.env
|
||||
runner/data/
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# mpq-packager — build image for the Gitea Action.
|
||||
#
|
||||
# Stage 1 builds StormLib (the MoPaQ library MPQEditor itself is built on) and
|
||||
# our small `mpqpack` binary against it. Stage 2 is a slim runtime carrying the
|
||||
# binary plus Python for the orchestration layer.
|
||||
|
||||
FROM debian:bookworm-slim AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git cmake g++ make zlib1g-dev libbz2-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
# Pin StormLib to a known-good tag for reproducible builds.
|
||||
ARG STORMLIB_REF=v9.25
|
||||
RUN git clone --depth 1 --branch "${STORMLIB_REF}" \
|
||||
https://github.com/ladislav-zezula/StormLib.git
|
||||
RUN cmake -S StormLib -B StormLib/build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DWITH_LIBTOMCRYPT=OFF \
|
||||
&& cmake --build StormLib/build --target storm -j "$(nproc)" \
|
||||
&& cmake --install StormLib/build
|
||||
|
||||
COPY src/mpqpack.c .
|
||||
# StormLib installs headers to /usr/local/include and libstorm.a to
|
||||
# /usr/local/lib. Link zlib/bz2 for sector (de)compression.
|
||||
RUN g++ -O2 -std=c++17 -o /usr/local/bin/mpqpack mpqpack.c \
|
||||
-I/usr/local/include -lstorm -lz -lbz2 \
|
||||
&& /usr/local/bin/mpqpack 2>/dev/null; \
|
||||
test -x /usr/local/bin/mpqpack
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-yaml zlib1g libbz2-1.0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/local/bin/mpqpack /usr/local/bin/mpqpack
|
||||
COPY package.py /opt/mpq-packager/package.py
|
||||
COPY entrypoint.sh /opt/mpq-packager/entrypoint.sh
|
||||
# Strip any CRLF (Windows checkouts) so the shebang and bash parse correctly.
|
||||
RUN sed -i 's/\r$//' /opt/mpq-packager/entrypoint.sh /opt/mpq-packager/package.py \
|
||||
&& chmod +x /opt/mpq-packager/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/opt/mpq-packager/entrypoint.sh"]
|
||||
@@ -1,2 +1,118 @@
|
||||
# mpq-packager
|
||||
|
||||
A CI packager for World of Warcraft **MPQ patch archives** — the MPQ equivalent
|
||||
of [BigWigsMods/packager](https://github.com/BigWigsMods/packager). Point it at a
|
||||
WoW interface repo and it produces a vanilla-1.12-compatible `patch-X.mpq`,
|
||||
ready to attach to a Gitea release.
|
||||
|
||||
It runs as a **Gitea Action** on a Linux runner. MPQs are built with
|
||||
[StormLib](https://github.com/ladislav-zezula/StormLib) (the MoPaQ library
|
||||
Ladik's MPQ Editor is itself built on) so no Windows host or Wine is needed.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads a manifest (`mpq.yaml`) describing what to pack and where it lands
|
||||
inside the archive.
|
||||
2. Stages the selected files, honouring ignore globs.
|
||||
3. Optionally substitutes `@project-version@` / `@project-revision@` /
|
||||
`@project-date-iso@` tokens in text files.
|
||||
4. Names the output `patch-<letter>.mpq` — vanilla 1.12's loader only accepts a
|
||||
**single-character** suffix (`patch-Z.mpq` ✅, `patch-myproject.mpq` ❌).
|
||||
5. Builds the archive as **MPQ format v1** with zlib-compressed sectors and a
|
||||
`(listfile)`.
|
||||
|
||||
## Usage in a consuming repo
|
||||
|
||||
Add a manifest at the repo root (`mpq.yaml`):
|
||||
|
||||
```yaml
|
||||
name: GlueXML
|
||||
patch-letter: Z # high letters sort last → win on conflict
|
||||
substitute:
|
||||
enabled: true
|
||||
extensions: [".lua", ".xml", ".toc"]
|
||||
contents:
|
||||
- src: . # repo root
|
||||
dest: Interface/GlueXML # in-MPQ path (slashes become backslashes)
|
||||
ignore:
|
||||
- ".git/**"
|
||||
- ".gitea/**"
|
||||
- "mpq.yaml"
|
||||
- "*.md"
|
||||
```
|
||||
|
||||
Add a release workflow at `.gitea/workflows/release.yml` (see
|
||||
[`examples/release.yml`](examples/release.yml)). Pushing a tag like `v1.0.0`
|
||||
builds the MPQ and attaches it to a matching Gitea release:
|
||||
|
||||
```yaml
|
||||
- uses: paste/mpq-packager@v1
|
||||
id: pack
|
||||
with:
|
||||
manifest: mpq.yaml
|
||||
version: ${{ github.ref_name }}
|
||||
# steps.pack.outputs.mpq-path / mpq-name point at the built archive
|
||||
```
|
||||
|
||||
Working examples for the GlueXML repo live in [`examples/`](examples/).
|
||||
|
||||
## Manifest reference
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----------------|--------------------|----------------------------------------------------------------|
|
||||
| `name` | — | Cosmetic, used in logs. |
|
||||
| `patch-letter` | `Z` | Single char; becomes `patch-<letter>.mpq`. |
|
||||
| `max-files` | file count + 16 | Hash-table sizing hint (StormLib rounds up to a power of two). |
|
||||
| `substitute` | disabled | `enabled` + `extensions` list for `@token@` replacement. |
|
||||
| `contents[].src` | `.` | Repo-relative file or directory to pack. |
|
||||
| `contents[].dest` | — | In-MPQ destination path (forward slashes; converted to `\`). |
|
||||
| `contents[].ignore` | `[]` | Glob patterns matched against repo-relative paths. |
|
||||
|
||||
## Action inputs / outputs
|
||||
|
||||
| Input | Default | Description |
|
||||
|------------|------------|----------------------------------------------------------|
|
||||
| `manifest` | `mpq.yaml` | Manifest path, relative to repo root. |
|
||||
| `version` | ref name | Stamped into `@project-version@`. |
|
||||
| `out-dir` | `dist` | Where the `.mpq` is written. |
|
||||
|
||||
| Output | Description |
|
||||
|------------|--------------------------------------|
|
||||
| `mpq-path` | Filesystem path of the built `.mpq`. |
|
||||
| `mpq-name` | Filename, e.g. `patch-Z.mpq`. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Actions enabled** on the consuming repo (repo Settings → Actions).
|
||||
- An **`act_runner`** registered to your Gitea instance advertising a label your
|
||||
workflow's `runs-on` uses (e.g. `ubuntu-latest`), with Docker available.
|
||||
|
||||
## Local build & test
|
||||
|
||||
```bash
|
||||
docker build -t mpq-packager .
|
||||
docker run --rm \
|
||||
-e INPUT_MANIFEST=mpq.yaml -e INPUT_VERSION=v0.0.0 \
|
||||
-e INPUT_OUT_DIR=/work/dist -e GITHUB_WORKSPACE=/work \
|
||||
-v "$PWD/test:/work" mpq-packager
|
||||
# -> test/dist/patch-Z.mpq
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|---------------------|-----------------------------------------------------------|
|
||||
| `src/mpqpack.c` | StormLib-based packer: staging dir → MPQ v1. |
|
||||
| `package.py` | Orchestration: manifest, staging, ignores, substitution. |
|
||||
| `entrypoint.sh` | Action entrypoint wiring `INPUT_*` env to `package.py`. |
|
||||
| `Dockerfile` | Builds StormLib + `mpqpack`; ships the runtime image. |
|
||||
| `action.yml` | Gitea/GitHub Docker action definition. |
|
||||
| `examples/` | Manifest + release workflow for a consuming repo. |
|
||||
| `test/` | Fixture + manifest exercised by CI. |
|
||||
|
||||
## Notes & caveats
|
||||
|
||||
- **`Blizzard_*` addons** still go through the engine's `.toc.sig` check
|
||||
regardless of MPQ residency — packaging them here does not bypass it.
|
||||
- Compression is zlib, which vanilla 1.12.1 decompresses. The output is MPQ v1
|
||||
specifically; v2+ headers are not read by the 1.12 loader.
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
name: "MPQ Packager"
|
||||
description: "Pack a WoW interface repo into a vanilla-compatible patch-X.mpq using StormLib."
|
||||
author: "paste"
|
||||
|
||||
inputs:
|
||||
manifest:
|
||||
description: "Path to the MPQ manifest, relative to the repo root."
|
||||
required: false
|
||||
default: "mpq.yaml"
|
||||
version:
|
||||
description: "Release version stamped into @project-version@ (defaults to the tag/ref name)."
|
||||
required: false
|
||||
default: ""
|
||||
out-dir:
|
||||
description: "Directory the built .mpq is written to."
|
||||
required: false
|
||||
default: "dist"
|
||||
|
||||
outputs:
|
||||
mpq-path:
|
||||
description: "Filesystem path of the built .mpq."
|
||||
mpq-name:
|
||||
description: "Filename of the built .mpq (e.g. patch-Z.mpq)."
|
||||
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
env:
|
||||
INPUT_MANIFEST: ${{ inputs.manifest }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_OUT_DIR: ${{ inputs.out-dir }}
|
||||
|
||||
branding:
|
||||
icon: "package"
|
||||
color: "purple"
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gitea Action entrypoint. Reads action inputs from the INPUT_* environment
|
||||
# variables Gitea/GitHub Actions set, then runs the packager.
|
||||
set -euo pipefail
|
||||
|
||||
manifest="${INPUT_MANIFEST:-mpq.yaml}"
|
||||
repo="${INPUT_REPO:-${GITHUB_WORKSPACE:-.}}"
|
||||
out_dir="${INPUT_OUT_DIR:-dist}"
|
||||
version="${INPUT_VERSION:-${GITHUB_REF_NAME:-}}"
|
||||
|
||||
# Derive revision/date from git when available; harmless if absent.
|
||||
revision=""
|
||||
date_iso=""
|
||||
if command -v git >/dev/null 2>&1 && git -C "$repo" rev-parse --short HEAD >/dev/null 2>&1; then
|
||||
revision="$(git -C "$repo" rev-parse --short HEAD)"
|
||||
date_iso="$(git -C "$repo" show -s --format=%cI HEAD)"
|
||||
fi
|
||||
|
||||
echo "mpq-packager: manifest=$manifest repo=$repo version=${version:-<none>}"
|
||||
|
||||
exec python3 /opt/mpq-packager/package.py \
|
||||
--manifest "$manifest" \
|
||||
--repo "$repo" \
|
||||
--out-dir "$out_dir" \
|
||||
--version "$version" \
|
||||
--revision "$revision" \
|
||||
--date "$date_iso"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Example manifest for the GlueXML repo. Copy this to the repo root as
|
||||
# `mpq.yaml`. It packs the whole repo into Interface\GlueXML\ inside a
|
||||
# patch-Z.mpq (Z sorts after Blizzard's stock MPQs, so its files win on conflict).
|
||||
|
||||
name: GlueXML
|
||||
patch-letter: Z
|
||||
|
||||
substitute:
|
||||
enabled: true
|
||||
extensions: [".lua", ".xml", ".toc"]
|
||||
|
||||
contents:
|
||||
- src: .
|
||||
dest: Interface/GlueXML
|
||||
ignore:
|
||||
- ".git/**"
|
||||
- ".gitea/**"
|
||||
- "mpq.yaml"
|
||||
- "*.md"
|
||||
- "dist/**"
|
||||
@@ -0,0 +1,69 @@
|
||||
# Example consuming workflow. Drop this in a WoW interface repo (e.g. GlueXML)
|
||||
# at .gitea/workflows/release.yml. Pushing a tag like `v1.0.0` builds the MPQ
|
||||
# and attaches it to a matching Gitea release.
|
||||
#
|
||||
# Requires: Actions enabled on the repo, and a runner advertising `ubuntu-latest`
|
||||
# with Docker available.
|
||||
#
|
||||
# NOTE on octowow.st: the instance is served under the /git subpath, but Gitea
|
||||
# generates URLs without it, which breaks actions/checkout and `uses:` action
|
||||
# resolution. So this workflow avoids both: it clones manually against the
|
||||
# correct base and runs the packager via `docker build` + `docker cp` (bind
|
||||
# mounts don't work either — the inner docker uses the host daemon). Once the
|
||||
# server's ROOT_URL is fixed, this can be simplified to actions/checkout +
|
||||
# `uses: paste/mpq-packager@v1`.
|
||||
|
||||
name: Release MPQ
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (manual; instance under /git)
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "https://gitea:${{ secrets.GITHUB_TOKEN }}@octowow.st/git/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "${{ github.ref }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Build packager image
|
||||
run: |
|
||||
git clone -q --depth 1 --branch v1 \
|
||||
"https://gitea:${{ secrets.GITHUB_TOKEN }}@octowow.st/git/paste/mpq-packager.git" /tmp/packager
|
||||
docker build -q -t mpq-packager /tmp/packager
|
||||
|
||||
- name: Build patch MPQ
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid=$(docker create \
|
||||
-e INPUT_MANIFEST=mpq.yaml \
|
||||
-e INPUT_VERSION=${{ github.ref_name }} \
|
||||
-e INPUT_OUT_DIR=/work/dist \
|
||||
-e GITHUB_WORKSPACE=/work \
|
||||
mpq-packager)
|
||||
docker cp . "$cid:/work"
|
||||
docker start -a "$cid"
|
||||
docker cp "$cid:/work/dist/patch-Z.mpq" ./patch-Z.mpq
|
||||
docker rm "$cid" >/dev/null
|
||||
ls -l patch-Z.mpq
|
||||
|
||||
- name: Create release and upload asset
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="https://octowow.st/git/api/v1/repos/${{ github.repository }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
RID=$(curl -fsSL -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
curl -fsSL -X POST "$API/releases/$RID/assets?name=patch-Z.mpq" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@patch-Z.mpq"
|
||||
echo "Released $TAG with patch-Z.mpq"
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage a repo into an MPQ patch according to a manifest, then invoke mpqpack.
|
||||
|
||||
This is the orchestration layer (the "BigWigs release.sh" equivalent). It:
|
||||
|
||||
1. reads a manifest (mpq.yaml) describing what to pack and where it lands
|
||||
inside the archive,
|
||||
2. copies the selected files into a temp staging tree, honouring ignore
|
||||
globs,
|
||||
3. optionally substitutes @keyword@ tokens (version/revision/date) in text
|
||||
files,
|
||||
4. names the output `patch-<letter>.mpq` per the vanilla 1.12 loader
|
||||
constraint (single-character suffix only), and
|
||||
5. calls the `mpqpack` binary to build the archive with StormLib.
|
||||
|
||||
Manifest schema (all keys optional except `contents`):
|
||||
|
||||
name: GlueXML # cosmetic, used in logs / release naming
|
||||
patch-letter: Z # single char A-Z; high letters sort last & win
|
||||
max-files: 4096 # hash-table sizing hint
|
||||
substitute:
|
||||
enabled: true
|
||||
extensions: [".lua", ".xml", ".toc"]
|
||||
contents:
|
||||
- src: . # path in the repo (file or dir)
|
||||
dest: Interface/GlueXML # in-MPQ path (forward slashes become backslashes)
|
||||
ignore: # glob patterns, matched against repo-relative paths
|
||||
- ".git/**"
|
||||
- ".gitea/**"
|
||||
- "mpq.yaml"
|
||||
- "*.md"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("package.py: PyYAML is required (pip install pyyaml)")
|
||||
|
||||
DEFAULT_IGNORES = [".git", ".git/**", ".gitea", ".gitea/**", "mpq.yaml"]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"package.py: {msg}", flush=True)
|
||||
|
||||
|
||||
def matches_any(rel_posix: str, patterns: list[str]) -> bool:
|
||||
"""True if rel_posix matches a glob, or sits under an ignored directory."""
|
||||
first = rel_posix.split("/", 1)[0]
|
||||
for pat in patterns:
|
||||
if fnmatch.fnmatch(rel_posix, pat):
|
||||
return True
|
||||
# bare directory name (e.g. ".git") ignores the whole subtree
|
||||
if "/" not in pat and "*" not in pat and first == pat:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def stage_entry(repo: Path, stage: Path, entry: dict) -> int:
|
||||
src = (repo / entry.get("src", ".")).resolve()
|
||||
dest_rel = entry["dest"].replace("\\", "/").strip("/")
|
||||
ignore = DEFAULT_IGNORES + list(entry.get("ignore", []))
|
||||
|
||||
if not src.exists():
|
||||
sys.exit(f"package.py: source not found: {src}")
|
||||
|
||||
copied = 0
|
||||
files = [src] if src.is_file() else sorted(p for p in src.rglob("*") if p.is_file())
|
||||
for f in files:
|
||||
rel = f.name if src.is_file() else f.relative_to(src).as_posix()
|
||||
if src.is_dir() and matches_any(rel, ignore):
|
||||
continue
|
||||
target = stage / dest_rel / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f, target)
|
||||
copied += 1
|
||||
log(f"staged {copied} files: {entry.get('src', '.')} -> {dest_rel.replace('/', chr(92))}")
|
||||
return copied
|
||||
|
||||
|
||||
def substitute_tokens(stage: Path, exts: list[str], tokens: dict[str, str]) -> None:
|
||||
pattern = re.compile("|".join(re.escape(k) for k in tokens))
|
||||
exts = {e.lower() for e in exts}
|
||||
n = 0
|
||||
for f in stage.rglob("*"):
|
||||
if not f.is_file() or f.suffix.lower() not in exts:
|
||||
continue
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
new = pattern.sub(lambda m: tokens[m.group(0)], text)
|
||||
if new != text:
|
||||
f.write_text(new, encoding="utf-8")
|
||||
n += 1
|
||||
if n:
|
||||
log(f"substituted version tokens in {n} files")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Build an MPQ patch from a manifest.")
|
||||
ap.add_argument("--manifest", default="mpq.yaml")
|
||||
ap.add_argument("--repo", default=".", help="repo root to package")
|
||||
ap.add_argument("--out-dir", default="dist")
|
||||
ap.add_argument("--version", default="", help="release version, e.g. v1.0.0")
|
||||
ap.add_argument("--revision", default="", help="git short hash")
|
||||
ap.add_argument("--date", default="", help="ISO build date")
|
||||
ap.add_argument("--mpqpack", default="mpqpack", help="path to mpqpack binary")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
manifest_path = repo / args.manifest
|
||||
if not manifest_path.exists():
|
||||
sys.exit(f"package.py: manifest not found: {manifest_path}")
|
||||
|
||||
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
||||
contents = manifest.get("contents")
|
||||
if not contents:
|
||||
sys.exit("package.py: manifest has no 'contents'")
|
||||
|
||||
letter = str(manifest.get("patch-letter", "Z")).strip()
|
||||
if len(letter) != 1 or not letter.isalnum():
|
||||
sys.exit(f"package.py: patch-letter must be a single char, got {letter!r}")
|
||||
|
||||
out_dir = Path(args.out_dir).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_mpq = out_dir / f"patch-{letter}.mpq"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="mpqstage-") as tmp:
|
||||
stage = Path(tmp)
|
||||
total = sum(stage_entry(repo, stage, e) for e in contents)
|
||||
if total == 0:
|
||||
sys.exit("package.py: nothing staged — check your manifest paths/ignores")
|
||||
|
||||
sub = manifest.get("substitute", {})
|
||||
if sub.get("enabled"):
|
||||
tokens = {
|
||||
"@project-version@": args.version,
|
||||
"@project-revision@": args.revision,
|
||||
"@project-date-iso@": args.date,
|
||||
}
|
||||
substitute_tokens(stage, sub.get("extensions", [".lua", ".xml", ".toc"]), tokens)
|
||||
|
||||
max_files = str(manifest.get("max-files", total + 16))
|
||||
log(f"building {out_mpq.name} from {total} files")
|
||||
rc = subprocess.run(
|
||||
[args.mpqpack, str(out_mpq), str(stage), max_files]
|
||||
).returncode
|
||||
if rc != 0:
|
||||
sys.exit(f"package.py: mpqpack failed (exit {rc})")
|
||||
|
||||
log(f"done -> {out_mpq}")
|
||||
# Expose the artifact path to a Gitea/GitHub Actions step.
|
||||
if gh_out := os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(gh_out, "a", encoding="utf-8") as fh:
|
||||
fh.write(f"mpq-path={out_mpq}\n")
|
||||
fh.write(f"mpq-name={out_mpq.name}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copy to `.env` (same folder) and fill in. Do NOT commit .env — it holds the token.
|
||||
# Get the token at: https://octowow.st/git/user/settings/actions/runners
|
||||
# -> "Create new Runner" -> copy the registration token.
|
||||
RUNNER_TOKEN=paste-registration-token-here
|
||||
RUNNER_NAME=octo-windows-runner
|
||||
@@ -0,0 +1,68 @@
|
||||
# act_runner setup (octowow.st)
|
||||
|
||||
Stands up a self-hosted Gitea Actions runner as a container. One runner
|
||||
registered at the **user level** serves all of your repos (`GlueXML`,
|
||||
`mpq-packager`, …).
|
||||
|
||||
## Prerequisites on the runner host
|
||||
|
||||
- **Docker** running. On Windows/macOS that means Docker Desktop with the Linux
|
||||
engine (WSL2 backend on Windows). Settings → Advanced → keep "Allow the
|
||||
default Docker socket to be used" enabled so the socket mount works.
|
||||
|
||||
## 1. Get a registration token
|
||||
|
||||
In the Gitea web UI, as your user:
|
||||
|
||||
> avatar → **Settings** → **Actions** → **Runners** → **Create new Runner**
|
||||
|
||||
Copy the **registration token** (a long string). This is user-scoped, so the
|
||||
runner picks up jobs from any of your repos.
|
||||
|
||||
Direct link: <https://octowow.st/git/user/settings/actions/runners>
|
||||
|
||||
> A repo-scoped token (repo → Settings → Actions → Runners) also works if you
|
||||
> only want the runner tied to one repo.
|
||||
|
||||
## 2. Configure
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# edit .env: paste RUNNER_TOKEN, set RUNNER_NAME
|
||||
```
|
||||
|
||||
## 3. Start
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
docker compose logs -f # watch it register
|
||||
```
|
||||
|
||||
The runner should appear as **Idle / online** in the Runners list within a few
|
||||
seconds.
|
||||
|
||||
## 4. Enable Actions on each repo
|
||||
|
||||
Per repo: **Settings → Actions →** enable. New repos have it off by default.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
Push a tag (or run a workflow) and watch it execute under the **Actions** tab.
|
||||
For `mpq-packager`'s release flow, a `v*` tag on a consuming repo builds the MPQ
|
||||
and attaches it to a release.
|
||||
|
||||
## Notes
|
||||
|
||||
- **First job is slow** — it pulls `catthehacker/ubuntu:act-latest` (~1 GB) the
|
||||
first time; cached afterward.
|
||||
- **`runs-on` labels** must match `GITEA_RUNNER_LABELS`. We map
|
||||
`ubuntu-latest` → `catthehacker/ubuntu:act-latest`. Add more pairs
|
||||
(comma-separated) to support other labels.
|
||||
- **Docker actions** (e.g. `uses: paste/mpq-packager@v1`) work out of the box:
|
||||
act_runner builds and runs them via the mounted host socket.
|
||||
- **Workflows that run `docker` CLI in a step** (like this repo's own
|
||||
`.gitea/workflows/ci.yml`) need the job container to reach Docker too. The
|
||||
release workflow does not — it only *uses* the Docker action — so it's fine.
|
||||
Ask if you want the CI's docker-in-job case wired up.
|
||||
- **Re-registering**: delete `./data` and `docker compose up -d` again with a
|
||||
fresh token.
|
||||
@@ -0,0 +1,26 @@
|
||||
# act_runner for octowow.st, run as a container (Docker Compose).
|
||||
# Copy this folder to the runner host, fill in .env (see .env.example),
|
||||
# then: docker compose up -d
|
||||
#
|
||||
# The runner auto-registers on first start using the env vars below, then
|
||||
# persists its credentials in ./data so restarts don't re-register.
|
||||
|
||||
services:
|
||||
act_runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: octo-act-runner
|
||||
restart: always
|
||||
environment:
|
||||
# Gitea base URL — note the /git path on this instance.
|
||||
GITEA_INSTANCE_URL: https://octowow.st/git
|
||||
# Registration token from Settings -> Actions -> Runners -> "Create new Runner".
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
|
||||
GITEA_RUNNER_NAME: ${RUNNER_NAME:-octo-runner}
|
||||
# runs-on label -> image mapping. ubuntu-latest uses the de-facto act image.
|
||||
GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-latest
|
||||
volumes:
|
||||
# Host Docker socket: act_runner spawns job/action containers as siblings.
|
||||
# On Docker Desktop (Windows/Mac) this path is provided by the Linux engine.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Persist runner registration + state.
|
||||
- ./data:/data
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* mpqpack — pack a staging directory tree into a vanilla-compatible MPQ patch.
|
||||
*
|
||||
* mpqpack <output.mpq> <staging-dir> [maxFiles]
|
||||
*
|
||||
* Every regular file under <staging-dir> is added to the archive. Its internal
|
||||
* (in-MPQ) name is the path relative to <staging-dir> with '/' rewritten to the
|
||||
* '\' separator WoW expects. So a staging layout of
|
||||
*
|
||||
* stage/Interface/GlueXML/CharacterSelect.lua
|
||||
*
|
||||
* is stored in the MPQ as Interface\GlueXML\CharacterSelect.lua.
|
||||
*
|
||||
* The archive is created as MPQ format v1 (32-bit) with zlib-compressed sectors
|
||||
* and a (listfile) — the combination vanilla 1.12.1 reads. See docs/MPQBuilding.md
|
||||
* in ClassicAPI for the empirical notes behind these choices.
|
||||
*/
|
||||
#include <StormLib.h>
|
||||
|
||||
#include <dirent.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static HANDLE g_mpq;
|
||||
static char g_root[PATH_MAX];
|
||||
static size_t g_rootlen;
|
||||
static unsigned g_added;
|
||||
|
||||
static void die(const char *what, const char *detail) {
|
||||
fprintf(stderr, "mpqpack: %s: %s (err %u)\n", what, detail, GetLastError());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* Build the in-MPQ name for a staged file and add it. */
|
||||
static void add_file(const char *fullpath) {
|
||||
const char *rel = fullpath + g_rootlen;
|
||||
while (*rel == '/') rel++; /* trim leading separator */
|
||||
|
||||
char internal[PATH_MAX];
|
||||
snprintf(internal, sizeof internal, "%s", rel);
|
||||
for (char *p = internal; *p; ++p)
|
||||
if (*p == '/') *p = '\\'; /* WoW wants backslashes */
|
||||
|
||||
DWORD flags = MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING;
|
||||
if (!SFileAddFileEx(g_mpq, fullpath, internal, flags,
|
||||
MPQ_COMPRESSION_ZLIB, MPQ_COMPRESSION_ZLIB))
|
||||
die("add", internal);
|
||||
|
||||
printf(" + %s\n", internal);
|
||||
g_added++;
|
||||
}
|
||||
|
||||
/* Recursively walk a directory. mode 0 = count only, mode 1 = add. */
|
||||
static unsigned walk(const char *dir, int add) {
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) die("opendir", dir);
|
||||
|
||||
unsigned count = 0;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d))) {
|
||||
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
|
||||
|
||||
char path[PATH_MAX];
|
||||
snprintf(path, sizeof path, "%s/%s", dir, e->d_name);
|
||||
|
||||
struct stat st;
|
||||
if (stat(path, &st)) die("stat", path);
|
||||
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
count += walk(path, add);
|
||||
} else if (S_ISREG(st.st_mode)) {
|
||||
if (add) add_file(path);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* --list <mpq>: enumerate archive contents (names + sizes). */
|
||||
static int cmd_list(const char *mpq) {
|
||||
HANDLE h;
|
||||
if (!SFileOpenArchive(mpq, 0, STREAM_FLAG_READ_ONLY, &h)) die("open", mpq);
|
||||
SFILE_FIND_DATA fd;
|
||||
HANDLE find = SFileFindFirstFile(h, "*", &fd, NULL);
|
||||
if (find) {
|
||||
do {
|
||||
printf("%-50s %u\n", fd.cFileName, fd.dwFileSize);
|
||||
} while (SFileFindNextFile(find, &fd));
|
||||
SFileFindClose(find);
|
||||
}
|
||||
SFileCloseArchive(h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --cat <mpq> <internal-name>: dump one file's bytes to stdout. */
|
||||
static int cmd_cat(const char *mpq, const char *name) {
|
||||
HANDLE h, f;
|
||||
if (!SFileOpenArchive(mpq, 0, STREAM_FLAG_READ_ONLY, &h)) die("open", mpq);
|
||||
if (!SFileOpenFileEx(h, name, 0, &f)) die("openfile", name);
|
||||
char buf[65536];
|
||||
DWORD got;
|
||||
while (SFileReadFile(f, buf, sizeof buf, &got, NULL) || got) {
|
||||
fwrite(buf, 1, got, stdout);
|
||||
if (got < sizeof buf) break;
|
||||
}
|
||||
SFileCloseFile(f);
|
||||
SFileCloseArchive(h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc >= 3 && !strcmp(argv[1], "--list"))
|
||||
return cmd_list(argv[2]);
|
||||
if (argc >= 4 && !strcmp(argv[1], "--cat"))
|
||||
return cmd_cat(argv[2], argv[3]);
|
||||
|
||||
if (argc < 3) {
|
||||
fprintf(stderr,
|
||||
"usage: %s <output.mpq> <staging-dir> [maxFiles]\n"
|
||||
" %s --list <mpq>\n"
|
||||
" %s --cat <mpq> <internal-name>\n",
|
||||
argv[0], argv[0], argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const char *out = argv[1];
|
||||
const char *stage = argv[2];
|
||||
|
||||
if (!realpath(stage, g_root)) die("resolve", stage);
|
||||
g_rootlen = strlen(g_root);
|
||||
|
||||
/* Count first so the hash table is sized to fit (v1 tables are fixed). */
|
||||
unsigned files = walk(g_root, 0);
|
||||
DWORD maxFiles = argc > 3 ? (DWORD)strtoul(argv[3], NULL, 10) : 0;
|
||||
if (maxFiles < files) maxFiles = files;
|
||||
if (maxFiles < 16) maxFiles = 16; /* StormLib rounds up to pow2 */
|
||||
|
||||
remove(out);
|
||||
DWORD createFlags = MPQ_CREATE_LISTFILE | MPQ_CREATE_ARCHIVE_V1;
|
||||
if (!SFileCreateArchive(out, createFlags, maxFiles, &g_mpq))
|
||||
die("create", out);
|
||||
|
||||
walk(g_root, 1);
|
||||
|
||||
if (!SFileFlushArchive(g_mpq)) die("flush", out);
|
||||
if (!SFileCloseArchive(g_mpq)) die("close", out);
|
||||
|
||||
printf("mpqpack: wrote %s (%u files)\n", out, g_added);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Fixture file packed by the CI smoke test. Version token below is substituted
|
||||
-- by the packager when substitute.enabled is true.
|
||||
local VERSION = "@project-version@"
|
||||
print("mpq-packager fixture, version " .. VERSION)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Manifest used by the CI smoke test.
|
||||
name: fixture
|
||||
patch-letter: Z
|
||||
substitute:
|
||||
enabled: true
|
||||
extensions: [".lua"]
|
||||
contents:
|
||||
- src: fixture
|
||||
dest: Interface/AddOns/Fixture
|
||||
Reference in New Issue
Block a user