diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..6752653 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,25 @@ +# Builds and tests every push and pull request (tests that need the game CD skip themselves). +name: build + +on: + push: + branches: ['**'] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 25 + 21 + - name: Build and test + run: | + ./gradlew --no-daemon \ + -Porg.gradle.java.installations.fromEnv=JAVA_HOME_25_X64 \ + -Porg.gradle.java.installations.auto-download=false \ + build diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f07dd24 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,61 @@ +# Builds and publishes a release when a version tag is pushed: +# git tag v0.2.1 && git push origin v0.2.1 +# The release gets the four packages (full/launcher x Windows/Linux), SHA256SUMS.txt and the +# matching CHANGELOG.md section as its text. Tests that need the game CD skip themselves here. +# +# Needs: Gitea Actions enabled and a runner with the label "ubuntu-latest" (act_runner). +# The built-in token is used; if it may not create releases on your instance, add a repository +# secret RELEASE_TOKEN (a token with write access to the repository). +name: release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: JDK 25 (toolchain) and 21 (runs Gradle) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 25 + 21 + + - name: Version from the tag + run: | + V="${GITHUB_REF_NAME#v}" + echo "VERSION=$V" >> "$GITHUB_ENV" + python3 tools/release/changelog_section.py "$V" > release-notes.md + echo "Release $V"; head -5 release-notes.md + + - name: Build, test and package + run: | + ./gradlew --no-daemon \ + -Porg.gradle.java.installations.fromEnv=JAVA_HOME_25_X64 \ + -Porg.gradle.java.installations.auto-download=false \ + -PreleaseVersion="$VERSION" \ + build packageAll + + - name: Checksums + run: | + cd build/dist + ls -la + sha256sum LlamaMod-"$VERSION"-*.zip LlamaMod-"$VERSION"-*.tar.gz > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Publish + env: + GITEA_URL: ${{ github.server_url }} + GITEA_REPO: ${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }} + run: | + python3 tools/release/publish_gitea.py \ + --tag "$GITHUB_REF_NAME" --title "LlamaMod $VERSION" --notes release-notes.md \ + build/dist/LlamaMod-"$VERSION"-*.zip build/dist/LlamaMod-"$VERSION"-*.tar.gz \ + build/dist/SHA256SUMS.txt diff --git a/README.md b/README.md index b661081..9def158 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,35 @@ More pictures: ## Get it -You need **your own copy of the game** (CD or ISO image). Development used the German CD; other -language versions should work but are untested. +You need **your own copy of the game** (the CD or an ISO image of it). Nothing else – LlamaMod +brings everything it needs (also Java). Development used the German CD; other language versions +should work but are untested. -| System | Package | Start | -|---|---|---| -| Windows | `LlamaMod--full-windows-x64.zip` | unzip, start `LlamaMod.exe` (no installation, no admin rights) | -| Linux | `LlamaMod--full-linux-x64.tar.gz` | unpack, start `./LlamaMod`; install **Wine** to run the game | +### Install in 5 steps -Both bring their own Java runtime; the editor needs OpenGL 3.3. A *launcher* edition without the -editor is also built. What changed: [CHANGELOG.md](CHANGELOG.md). +1. **Download** from the **Releases** page of this repository (top of the page): + * Windows: `LlamaMod--full-windows-x64.zip` + * Linux: `LlamaMod--full-linux-x64.tar.gz` + + (*full* = with the level editor. The *launcher* packages are the same without the editor.) +2. **Unpack** it into any folder you like (for example *Documents* or your home folder). + There is no installer and you need no admin rights. +3. **Start** LlamaMod: + * Windows: double-click **`LlamaMod.exe`**. If Windows says *"Windows protected your PC"*, + click **More info → Run anyway** (the program is not signed, that's all). + * Linux: install Wine once (`sudo apt install wine` on Ubuntu/Mint/Debian), then double-click + **`LlamaMod`** or run `./LlamaMod`. `./install-desktop-entry.sh` adds it to your app menu. +4. **Set up the game** – on the first start a short wizard asks for your game: put the CD in or + pick the ISO file, click **Set up** and wait about a minute. Your CD/ISO is only read, never + changed. +5. **Play** – on the **Mods** tab tick the mods you want, click **Apply**, then **Play**. + *Restore original* switches all mods off again. + +New levels (Test World, Alligator Arena, Llama Coaster, your own) are in the hidden world +**TestLevel**: open the **Save games** tab once and click **Unlock TestLevel**, then pick it on the +world map. If your antivirus complains about `groove.exe`: mods that change the game program are +marked *modifies groove.exe*; LlamaMod tells you what to do. More help: +[Troubleshooting](docs/manual/20-troubleshooting.md). What changed: [CHANGELOG.md](CHANGELOG.md). ### Build from source diff --git a/build.gradle b/build.gradle index f72c250..8432be7 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,8 @@ plugins { } group = 'com.eliasmueller.llamamod' -version = '0.2.0' +// Release builds pass -PreleaseVersion= (see .gitea/workflows/release.yml). +version = findProperty('releaseVersion') ?: '0.2.1' repositories { mavenCentral() diff --git a/docs/developers.md b/docs/developers.md index e58b3cb..992c6b4 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -84,3 +84,25 @@ Maintenance notes: [notes/editor-manual.md](../notes/editor-manual.md). [`notes/`](../notes/README.md) is the project's lab notebook: one file per format, editor area and mod, with addresses in `groove.exe`, confidence levels, what was verified in the game and known limits. Process material (agent briefs, review reports) is in [`notes/dev/`](../notes/dev/). + +## Releasing + +Releases are built by Gitea Actions ([.gitea/workflows/release.yml](../.gitea/workflows/release.yml)): + +1. Add a `## – …` section at the top of [CHANGELOG.md](../CHANGELOG.md) (it becomes the + release text; the workflow fails if it is missing). +2. `git tag v && git push origin v` + +The workflow builds and tests (tests that need the game CD skip themselves on the runner), runs +`packageAll` with `-PreleaseVersion=` (the tag decides the version; without it +`build.gradle`'s default is used), writes `SHA256SUMS.txt` and creates the release with the four +packages via [tools/release/publish_gitea.py](../tools/release/publish_gitea.py) (standard library +only; re-running replaces files of the same name). [.gitea/workflows/build.yml](../.gitea/workflows/build.yml) +builds every push. + +Requirements on the Gitea side: Actions enabled (`[actions] ENABLED = true`), an `act_runner` with +the label `ubuntu-latest` (an image with `python3`, `curl`/network access – the build downloads the +Temurin JDK/jmods and launch4j), and an attachment size limit above ~60 MB (`[attachment] MAX_SIZE` +in `app.ini`). If the built-in token may not create releases, add a repository secret +`RELEASE_TOKEN` with write access. Locally the same packages come from +`./gradlew -PreleaseVersion= packageAll` (output in `build/dist`). diff --git a/tools/release/changelog_section.py b/tools/release/changelog_section.py new file mode 100644 index 0000000..ac32d8f --- /dev/null +++ b/tools/release/changelog_section.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Prints the CHANGELOG.md section of one version (used as the release text). + + changelog_section.py 0.2.1 [CHANGELOG.md] + +A section starts at a "## " heading (anything may follow the version, e.g. " – changes +since 0.1.0") and ends before the next "## " heading. Exit code 1 if the version has no section. +""" +import re +import sys + + +def section(text, version): + out, inside = [], False + for line in text.splitlines(): + if line.startswith("## "): + if inside: + break + inside = re.match(r"## v?" + re.escape(version) + r"(\s|$)", line) is not None + continue + if inside: + out.append(line) + return "\n".join(out).strip() if inside or out else None + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + path = sys.argv[2] if len(sys.argv) > 2 else "CHANGELOG.md" + with open(path, encoding="utf-8") as f: + s = section(f.read(), sys.argv[1]) + if not s: + sys.exit("no section for version %s in %s" % (sys.argv[1], path)) + print(s) + + +if __name__ == "__main__": + main() diff --git a/tools/release/publish_gitea.py b/tools/release/publish_gitea.py new file mode 100644 index 0000000..11165b5 --- /dev/null +++ b/tools/release/publish_gitea.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Creates (or reuses) a Gitea release for a tag and uploads files to it. + + publish_gitea.py --tag v0.2.1 --title "LlamaMod 0.2.1" --notes notes.md FILE... + +Environment: GITEA_URL (e.g. https://git.example.com), GITEA_REPO (owner/name), GITEA_TOKEN +(a token with write access to the repository). In Gitea Actions these come from the workflow. +Files that already exist on the release with the same name are replaced, so a re-run is safe. +Only the standard library is used. +""" +import argparse +import json +import mimetypes +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +def api(method, url, token, data=None, headers=None): + h = {"Authorization": "token " + token, "Accept": "application/json"} + h.update(headers or {}) + req = urllib.request.Request(url, data=data, method=method, headers=h) + try: + with urllib.request.urlopen(req) as r: + body = r.read() + return r.status, json.loads(body) if body else None + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", "replace") + + +def multipart(path): + boundary = uuid.uuid4().hex + name = os.path.basename(path) + ctype = mimetypes.guess_type(name)[0] or "application/octet-stream" + with open(path, "rb") as f: + content = f.read() + head = ('--%s\r\nContent-Disposition: form-data; name="attachment"; filename="%s"\r\n' + "Content-Type: %s\r\n\r\n" % (boundary, name, ctype)).encode() + tail = ("\r\n--%s--\r\n" % boundary).encode() + return head + content + tail, "multipart/form-data; boundary=" + boundary + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--tag", required=True) + ap.add_argument("--title", required=True) + ap.add_argument("--notes", help="file with the release text (Markdown)") + ap.add_argument("--prerelease", action="store_true") + ap.add_argument("files", nargs="+") + a = ap.parse_args() + base, repo, token = (os.environ.get(k, "") for k in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN")) + if not (base and repo and token): + sys.exit("set GITEA_URL, GITEA_REPO and GITEA_TOKEN") + root = "%s/api/v1/repos/%s" % (base.rstrip("/"), repo) + body = open(a.notes, encoding="utf-8").read() if a.notes else "" + + st, rel = api("GET", "%s/releases/tags/%s" % (root, urllib.parse.quote(a.tag)), token) + if st == 404: + payload = json.dumps({"tag_name": a.tag, "name": a.title, "body": body, + "draft": False, "prerelease": a.prerelease}).encode() + st, rel = api("POST", root + "/releases", token, payload, {"Content-Type": "application/json"}) + if st not in (200, 201): + sys.exit("creating the release failed (%s): %s" % (st, rel)) + print("created release", rel["id"], a.tag) + elif st == 200: + print("reusing release", rel["id"], a.tag) + else: + sys.exit("reading the release failed (%s): %s" % (st, rel)) + + existing = {x["name"]: x["id"] for x in rel.get("assets") or []} + for path in a.files: + name = os.path.basename(path) + if name in existing: + api("DELETE", "%s/releases/%s/assets/%s" % (root, rel["id"], existing[name]), token) + data, ctype = multipart(path) + st, res = api("POST", "%s/releases/%s/assets?name=%s" % (root, rel["id"], urllib.parse.quote(name)), + token, data, {"Content-Type": ctype}) + if st not in (200, 201): + sys.exit("uploading %s failed (%s): %s" % (name, st, res)) + print("uploaded", name, "(%.1f MB)" % (len(data) / 1e6)) + + +if __name__ == "__main__": + main()