diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..38990c4 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,41 @@ +# Builds the .deb when a version tag is pushed and publishes it as a release, +# with that version's CHANGELOG.md section as the release notes. To release, +# set VERSION and CHANGELOG.md, commit, then: +# git tag v$(cat VERSION) && git push origin v$(cat VERSION) +name: release + +on: + push: + tags: ['v*'] + +jobs: + deb: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check that the tag matches VERSION + run: | + if [ "$GITHUB_REF_NAME" != "v$(cat VERSION)" ]; then + echo "tag $GITHUB_REF_NAME doesn't match VERSION ($(cat VERSION))" + exit 1 + fi + + - name: Install build tools + env: + DEBIAN_FRONTEND: noninteractive + run: | + SUDO=$(command -v sudo || true) + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq make python3 dpkg shellcheck python3-pyflakes + + - name: Build (make check runs first) + run: make deb + + - name: Publish the release + env: + GITEA_URL: ${{ gitea.server_url }} + GITEA_REPO: ${{ gitea.repository }} + # The job's own token; set a RELEASE_TOKEN secret instead if it isn't allowed to write releases + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} + run: python3 packaging/gitea-release.py "$GITHUB_REF_NAME" build/*.deb diff --git a/.gitignore b/.gitignore index 93a22b9..af93f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ *.deb +__pycache__/ diff --git a/README.md b/README.md index 9e1ff73..da60c8b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,13 @@ make check # bash -n on all scripts, plus shellcheck if installed make deb # build/terraria-server-manager__all.deb ``` -To release: update `VERSION` and `CHANGELOG.md`, run `make deb`, and attach the `.deb` to the release. +To release, update `VERSION` and `CHANGELOG.md`, commit, and push a matching tag: + +```bash +git tag v$(cat VERSION) && git push origin v$(cat VERSION) +``` + +The `release` workflow (`.gitea/workflows/release.yaml`, Gitea Actions) then builds the `.deb` and publishes it as a release, with that version's changelog section as the notes. It needs a runner with the `ubuntu-latest` label. To build a package by hand instead, run `make deb`. ## Disclaimer diff --git a/packaging/gitea-release.py b/packaging/gitea-release.py new file mode 100644 index 0000000..88df3de --- /dev/null +++ b/packaging/gitea-release.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# Creates or updates the Gitea release for a tag and attaches files to it. Its +# notes are that version's section of CHANGELOG.md. Run again for the same tag, +# it updates the notes and replaces files of the same name. +# +# gitea-release.py ... +# +# Needs GITEA_URL (https://gitea.example.com), GITEA_REPO (owner/name) and +# GITEA_TOKEN. Used by .gitea/workflows/release.yaml. + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +def api(method, path, body=None, content_type="application/json"): + url = f"{os.environ['GITEA_URL'].rstrip('/')}/api/v1/repos/{os.environ['GITEA_REPO']}{path}" + if isinstance(body, dict): + body = json.dumps(body).encode() + headers = {"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Accept": "application/json"} + if body is not None: + headers["Content-Type"] = content_type + request = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=300) as response: + data = response.read() + return json.loads(data) if data else None + except urllib.error.HTTPError as e: + if e.code == 404 and method == "GET": + return None + sys.exit(f"{method} {url}: HTTP {e.code}: {e.read().decode(errors='replace')}") + + +def changelog_section(version): + """The lines under '## ' in CHANGELOG.md, up to the next '## '.""" + lines, found = [], False + with open("CHANGELOG.md") as f: + for line in f: + if line.startswith("## "): + if found: + break + found = line[3:].split()[0] == version + continue + if found: + lines.append(line) + if not found: + sys.exit(f"CHANGELOG.md has no '## {version}' section") + return "".join(lines).strip() + "\n" + + +def upload(release_id, path): + name = os.path.basename(path) + boundary = uuid.uuid4().hex + with open(path, "rb") as f: + content = f.read() + body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n").encode() + content + f"\r\n--{boundary}--\r\n".encode() + api("POST", f"/releases/{release_id}/assets?name={urllib.parse.quote(name)}", body, + f"multipart/form-data; boundary={boundary}") + print(f"attached {name}") + + +def main(tag, files): + notes = changelog_section(tag[1:] if tag.startswith("v") else tag) + release = api("GET", f"/releases/tags/{urllib.parse.quote(tag)}") + if release is None: + release = api("POST", "/releases", {"tag_name": tag, "name": tag, "body": notes}) + print(f"created release {tag}") + else: + release = api("PATCH", f"/releases/{release['id']}", {"body": notes}) + print(f"updated release {tag}") + names = {os.path.basename(p) for p in files} + for asset in release.get("assets") or []: + if asset["name"] in names: + api("DELETE", f"/releases/{release['id']}/assets/{asset['id']}") + for path in files: + upload(release["id"], path) + print(release.get("html_url", "")) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit("usage: gitea-release.py ...") + for var in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN"): + if not os.environ.get(var): + sys.exit(f"{var} is not set") + main(sys.argv[1], sys.argv[2:])