diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e5fb6dd --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7f15fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.DS_Store +.release +.install +.lua/* +.vscode +.idea +dist/ +tmp/ +staging/ diff --git a/README.md b/README.md index 027c603..682456f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,92 @@ # coa-pack -Bundles every Exiles/coa-* addon's latest release into one ExilesPack-.zip for guildies. \ No newline at end of file +Bundles every `Exiles/coa-*` addon's latest Gitea release into one +`ExilesPack-.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 `, and attaches the resulting +`ExilesPack-.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-.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-` 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 `-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 `/` 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. diff --git a/manifest.yaml b/manifest.yaml new file mode 100644 index 0000000..6a4c3c3 --- /dev/null +++ b/manifest.yaml @@ -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 diff --git a/tools/build_pack.sh b/tools/build_pack.sh new file mode 100755 index 0000000..f4477ac --- /dev/null +++ b/tools/build_pack.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# build_pack.sh - assemble ExilesPack-.zip (or ExilesPack-.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 <] + +Without --tag, the pack is named ExilesPack-.zip. +With --tag, the pack is named ExilesPack-.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 "" 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 -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 "============================================================" diff --git a/tools/list_releases.sh b/tools/list_releases.sh new file mode 100755 index 0000000..5892581 --- /dev/null +++ b/tools/list_releases.sh @@ -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)