feat: initial coa-pack scaffolding (manifest + build_pack + workflow + docs)

This commit is contained in:
2026-05-25 12:11:48 +02:00
parent 7eb8d60448
commit fa6dc68d31
7 changed files with 485 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
* -text
+48
View File
@@ -0,0 +1,48 @@
name: release-pack
on:
push:
tags:
- '20*' # date-based: 2026.05.25 etc.
jobs:
pack:
runs-on: linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build pack
run: bash tools/build_pack.sh --tag "${{ github.ref_name }}"
- name: Publish release (Gitea API direct; no action dependency)
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
API: ${{ github.server_url }}/api/v1
run: |
set -euo pipefail
# Create the release (or reuse if it already exists for this tag).
RID=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$API/repos/$REPO/releases/tags/$TAG" 2>/dev/null \
| jq -r '.id // empty')
if [ -z "$RID" ]; then
RID=$(curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"$API/repos/$REPO/releases" \
-d "$(jq -nc --arg t "$TAG" '{tag_name:$t,name:$t,draft:false,prerelease:false}')" \
| jq -r '.id')
fi
echo "release id: $RID"
# Upload every dist/*.zip
for f in dist/*.zip; do
name=$(basename "$f")
echo "uploading $name"
curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" \
"$API/repos/$REPO/releases/$RID/assets?name=$name" \
| jq -r '" -> " + .browser_download_url'
done
+10
View File
@@ -0,0 +1,10 @@
.env
.DS_Store
.release
.install
.lua/*
.vscode
.idea
dist/
tmp/
staging/
+90 -1
View File
@@ -1,3 +1,92 @@
# coa-pack
Bundles every Exiles/coa-* addon's latest release into one ExilesPack-<YYYY.MM.DD>.zip for guildies.
Bundles every `Exiles/coa-*` addon's latest Gitea release into one
`ExilesPack-<YYYY.MM.DD>.zip` for guildies. This repo is not itself an addon;
it's the pack builder.
The output zip unpacks straight into `Interface/AddOns/` - every entry at the
top level of the zip is an addon folder.
## Cut a release
```
git tag 2026.05.25
git push --tags
```
The `.gitea/workflows/release.yml` workflow picks up any tag starting with
`20*`, runs `tools/build_pack.sh --tag <tag>`, and attaches the resulting
`ExilesPack-<tag>.zip` to a Gitea release.
The pack lands at
[git.sub-net.at/Exiles/coa-pack/releases](https://git.sub-net.at/Exiles/coa-pack/releases).
## Build locally
```
bash tools/build_pack.sh # ExilesPack-<UTC date>.zip
bash tools/build_pack.sh --tag 2026.05.25
```
Produces `dist/ExilesPack-*.zip` and prints a summary (addon count, size,
sha256, repos included/skipped).
Dependencies: `bash`, `curl`, `jq`, `unzip`, `zip`. No `yq`.
## Peek at what's currently included
```
bash tools/list_releases.sh
```
Lists every `include: true` repo in `manifest.yaml` with its latest release
tag (or `(no releases)`). Pure read-only against the public Gitea API.
## manifest.yaml
Controls which repos go in the pack. Format:
```yaml
addons:
- repo: coa-decursive
include: true
- repo: coa-ace3
include: false
note: "Ace3 libs are already embedded inside each consuming fork."
```
- `include: true` - fetch the latest release and unpack its zip assets into
the pack.
- `include: false` - leave it out (libraries, repo-only utilities,
not-yet-ready forks, etc.).
To add a new addon: append a `- repo: coa-<name>` block with `include: true`.
To drop one without deleting the entry, flip its flag to `false` (and add a
`note:` so the reason survives).
## Asset selection rules
For each included repo, the builder grabs the latest release and chooses
which zip(s) to extract:
1. If a `<repo>-all.zip` asset is present (umbrella zip with every addon
folder side-by-side), use just that.
2. Otherwise, download every `*.zip` asset attached to the release.
Each chosen zip is unpacked straight into `staging/`, where it should
produce one or more `<AddonFolder>/` entries. The per-repo zips don't carry
repo metadata (`.git`, `README.md`, `.gitea/`), so the pack stays clean.
## Failure modes
- Repo has no releases yet: warned and skipped (the pack still builds).
- Asset download fails: one retry, then warned and skipped.
- All repos skip: build errors out (`staging is empty`).
This means cutting a pack release while some addons are still tagless will
produce a smaller-than-final pack rather than a hard failure. Re-tag once
all repos have shipped.
## License
[0BSD](LICENSE). Matches the rest of the Exiles addon repos.
+90
View File
@@ -0,0 +1,90 @@
pack:
name: ExilesPack
default_branch: master
source: https://git.sub-net.at/Exiles
# Repos to include in the pack. `include: false` means the repo is in the org but
# either contains libraries only (coa-ace3) or isn't an end-user addon.
addons:
- repo: coa-ace3 # canonical Ace3 bundle - libs, not deployed as addon
include: false
note: "Ace3 libs are already embedded inside each consuming fork; the pack doesn't need them again."
- repo: coa-ai-voiceover
include: true
- repo: coa-altoholic
include: true
# If a repo has a *-all.zip release asset, prefer it over individual zips. Otherwise take all per-addon zips.
- repo: coa-atlasloot
include: true
- repo: coa-bagnon
include: true
- repo: coa-bartender
include: true
- repo: coa-chatter
include: true
- repo: coa-clique
include: true
- repo: coa-dbm
include: true
- repo: coa-decursive
include: true
- repo: coa-details
include: true
- repo: coa-elvui
include: true
note: "Repo-only - not part of the default Exiles install. Set include:false if you want a slimmer pack."
- repo: coa-exporter
include: true
note: "Guild-only addon (CoaExporter)."
- repo: coa-kui-nameplates
include: true
- repo: coa-leatrix-plus
include: true
- repo: coa-moveanything
include: true
- repo: coa-omen
include: true
- repo: coa-pawn
include: true
- repo: coa-professionmenu
include: true
- repo: coa-quartz
include: true
- repo: coa-ratingbuster
include: true
- repo: coa-sexymap
include: true
- repo: coa-shadowedunitframes
include: true
- repo: coa-tsm
include: true
- repo: coa-vanillaguide
include: true
note: "Repo-only - not part of the default Exiles install."
- repo: coa-weakauras
include: true
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# build_pack.sh - assemble ExilesPack-<YYYY.MM.DD>.zip (or ExilesPack-<tag>.zip with --tag)
#
# Reads manifest.yaml, fetches the latest release of every `include: true` repo
# from git.sub-net.at/Exiles, downloads the chosen zip asset(s), unpacks them
# into staging/, and rolls the combined result up into dist/ExilesPack-*.zip.
#
# We unpack the per-addon release zips (which contain just the addon folder,
# nothing else), so .git/.github/.gitea/README.md never enter the pack.
#
# Deps: bash, curl, jq, unzip, zip. No yq needed - the manifest is parsed
# with a tiny awk pass tailored to its simple shape.
set -euo pipefail
# ---------------------------------------------------------------------------
# Paths + args
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MANIFEST="$REPO_ROOT/manifest.yaml"
TMP_DIR="$REPO_ROOT/tmp"
STAGING_DIR="$REPO_ROOT/staging"
DIST_DIR="$REPO_ROOT/dist"
API="https://git.sub-net.at/api/v1"
ORG="Exiles"
TAG=""
while [ $# -gt 0 ]; do
case "$1" in
--tag) TAG="$2"; shift 2 ;;
--tag=*) TAG="${1#--tag=}"; shift ;;
-h|--help)
cat <<USAGE
Usage: $0 [--tag <tag>]
Without --tag, the pack is named ExilesPack-<UTC YYYY.MM.DD>.zip.
With --tag, the pack is named ExilesPack-<tag>.zip.
USAGE
exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
if [ -z "$TAG" ]; then
PACK_NAME="ExilesPack-$(date -u +%Y.%m.%d).zip"
else
PACK_NAME="ExilesPack-${TAG}.zip"
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { printf '[build_pack] %s\n' "$*"; }
warn() { printf '[build_pack] WARN: %s\n' "$*" >&2; }
die() { printf '[build_pack] ERROR: %s\n' "$*" >&2; exit 1; }
# Parse manifest.yaml -> emit "<repo>" for every `include: true` entry.
# The manifest is a small fixed-shape YAML (no anchors, no nested lists), so a
# 20-line awk pass is more honest than pulling in a yq dependency.
manifest_includes() {
awk '
/^[[:space:]]*-[[:space:]]*repo:[[:space:]]*/ {
sub(/^[[:space:]]*-[[:space:]]*repo:[[:space:]]*/, "")
gsub(/[[:space:]]+$/, "")
repo = $0
include = ""
next
}
/^[[:space:]]+include:[[:space:]]*/ {
sub(/^[[:space:]]+include:[[:space:]]*/, "")
gsub(/[[:space:]]+$/, "")
include = $0
if (repo != "" && include == "true") { print repo }
repo = ""; include = ""
}
' "$MANIFEST"
}
# Latest release JSON for a repo, or empty string if none.
latest_release_json() {
local repo="$1" json
json="$(curl -fsS "$API/repos/$ORG/$repo/releases?limit=1" 2>/dev/null || echo '[]')"
# `releases?limit=1` returns an array. Strip the wrapper.
if [ "$(printf '%s' "$json" | jq -r 'type')" = "array" ]; then
printf '%s' "$json" | jq -c '.[0] // empty'
else
printf ''
fi
}
# Download one URL with one retry. Stream to file.
fetch_with_retry() {
local url="$1" out="$2"
for attempt in 1 2; do
if curl -fsSL --retry 0 -o "$out" "$url"; then
return 0
fi
warn "download attempt $attempt failed for $url"
sleep 2
done
return 1
}
# ---------------------------------------------------------------------------
# Reset workspace
# ---------------------------------------------------------------------------
rm -rf "$TMP_DIR" "$STAGING_DIR"
mkdir -p "$TMP_DIR" "$STAGING_DIR" "$DIST_DIR"
INCLUDED=0
SKIPPED=0
SKIPPED_REPOS=()
# ---------------------------------------------------------------------------
# Per-repo: pick assets, download, extract
# ---------------------------------------------------------------------------
while read -r repo; do
[ -z "$repo" ] && continue
log "processing $repo"
rel="$(latest_release_json "$repo")"
if [ -z "$rel" ] || [ "$rel" = "null" ]; then
warn "$repo has no releases yet - skipping"
SKIPPED=$((SKIPPED + 1))
SKIPPED_REPOS+=("$repo")
continue
fi
tag="$(printf '%s' "$rel" | jq -r '.tag_name')"
log " latest release: $tag"
# Prefer <repo>-all.zip if present, otherwise take every *.zip asset.
assets_all="$(printf '%s' "$rel" | jq -r --arg n "${repo}-all.zip" \
'.assets[]? | select(.name == $n) | .browser_download_url')"
if [ -n "$assets_all" ]; then
download_urls="$assets_all"
log " using ${repo}-all.zip"
else
download_urls="$(printf '%s' "$rel" | jq -r \
'.assets[]? | select(.name | endswith(".zip")) | .browser_download_url')"
fi
if [ -z "$download_urls" ]; then
warn "$repo release $tag has no .zip assets - skipping"
SKIPPED=$((SKIPPED + 1))
SKIPPED_REPOS+=("$repo")
continue
fi
mkdir -p "$TMP_DIR/$repo"
any_ok=0
while IFS= read -r url; do
[ -z "$url" ] && continue
name="$(basename "$url")"
log " downloading $name"
if fetch_with_retry "$url" "$TMP_DIR/$repo/$name"; then
if unzip -q -o "$TMP_DIR/$repo/$name" -d "$STAGING_DIR"; then
any_ok=1
else
warn " unzip failed for $name"
fi
else
warn " could not download $name after retry"
fi
done <<<"$download_urls"
if [ "$any_ok" -eq 1 ]; then
INCLUDED=$((INCLUDED + 1))
else
SKIPPED=$((SKIPPED + 1))
SKIPPED_REPOS+=("$repo")
fi
done < <(manifest_includes)
# ---------------------------------------------------------------------------
# Build the pack
# ---------------------------------------------------------------------------
if [ ! -d "$STAGING_DIR" ] || [ -z "$(ls -A "$STAGING_DIR" 2>/dev/null)" ]; then
die "staging is empty - no addons were successfully fetched"
fi
OUT="$DIST_DIR/$PACK_NAME"
rm -f "$OUT"
( cd "$STAGING_DIR" && zip -qr "$OUT" . )
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
ADDON_FOLDERS="$(cd "$STAGING_DIR" && find . -mindepth 1 -maxdepth 1 -type d | wc -l)"
SIZE="$(stat -c '%s' "$OUT")"
SHA="$(sha256sum "$OUT" | awk '{print $1}')"
echo
echo "============================================================"
echo " pack: $PACK_NAME"
echo " path: $OUT"
echo " addon dirs: $ADDON_FOLDERS"
echo " size: $SIZE bytes"
echo " sha256: $SHA"
echo " repos in: $INCLUDED"
echo " repos out: $SKIPPED${SKIPPED_REPOS[*]:+ (${SKIPPED_REPOS[*]})}"
echo "============================================================"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# List the most-recent release tag for every includable repo in manifest.yaml.
# Pure read-only via Gitea API. No auth needed - the Exiles org repos are public.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MANIFEST="$REPO_ROOT/manifest.yaml"
API="https://git.sub-net.at/api/v1"
ORG="Exiles"
# Same minimal-awk parser as build_pack.sh.
manifest_includes() {
awk '
/^[[:space:]]*-[[:space:]]*repo:[[:space:]]*/ {
sub(/^[[:space:]]*-[[:space:]]*repo:[[:space:]]*/, "")
gsub(/[[:space:]]+$/, "")
repo = $0; include = ""; next
}
/^[[:space:]]+include:[[:space:]]*/ {
sub(/^[[:space:]]+include:[[:space:]]*/, "")
gsub(/[[:space:]]+$/, "")
include = $0
if (repo != "" && include == "true") { print repo }
repo = ""; include = ""
}
' "$MANIFEST"
}
while read -r repo; do
[ -z "$repo" ] && continue
json="$(curl -fsS "$API/repos/$ORG/$repo/releases?limit=1" 2>/dev/null || echo '[]')"
tag="$(printf '%s' "$json" | jq -r '.[0].tag_name // empty')"
date="$(printf '%s' "$json" | jq -r '.[0].published_at // empty' | cut -dT -f1)"
if [ -z "$tag" ]; then
printf '%-30s (no releases)\n' "$repo"
else
printf '%-30s %-20s %s\n' "$repo" "$tag" "$date"
fi
done < <(manifest_includes)