diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ec4585f..705ee2e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -35,3 +35,20 @@ jobs: run: shellcheck deploy/*.sh - name: pytest (inkl. Migrationen auf befüllter Datenbank) run: pytest -q + + # Tag vX.Y.Z → Gitea-Release mit dem Abschnitt aus CHANGELOG.md (nur wenn alles grün) + release: + needs: pruefen + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Release anlegen + run: python3 scripts/gitea_release.py "${GITHUB_REF_NAME}" + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: ${{ github.server_url }} + GITEA_REPO: ${{ github.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a06ae9..862ba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +### Hinzugefügt +- Gitea-Releases mit dem Changelog-Abschnitt als Text: `scripts/gitea_release.py` + (einzeln oder `--all`) und automatisch per CI bei jedem Versions-Tag. + ## [0.11.0] – 2026-10-24 ### Hinzugefügt diff --git a/README.md b/README.md index 3a388d5..afa3e82 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,10 @@ registriert. [CHANGELOG.md](CHANGELOG.md). Bei einem Release: 1. CI grün? Dann den Abschnitt `[Unreleased]` mit Versionsnummer versehen. 2. Die Version in `src/netmover/__init__.py` anheben. - 3. Einen Tag `vX.Y.Z` setzen. + 3. Einen annotierten Tag `vX.Y.Z` setzen (Betreff = Titel des Releases) und pushen. + 4. Release in Gitea: macht die CI nach grünem Lauf selbst; von Hand mit + `scripts/gitea_release.py vX.Y.Z` (Token in `~/.config/netmover/gitea-token`, + Modus 0600). `--all` legt fehlende Releases für alle Tags an. - Arbeitszeit und Entscheidungen stehen in [DEVLOG.md](DEVLOG.md). - htmx 2.0.4 liegt unter `src/netmover/web/static/htmx.min.js` (SHA-256 `e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`). diff --git a/scripts/gitea_release.py b/scripts/gitea_release.py new file mode 100755 index 0000000..edeb56a --- /dev/null +++ b/scripts/gitea_release.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Gitea-Release zu einem Tag anlegen oder aktualisieren – Text aus CHANGELOG.md. + + scripts/gitea_release.py v0.11.0 # ein Tag + scripts/gitea_release.py --all # alle Tags (bestehende Releases werden aktualisiert) + +Token (Gitea → Einstellungen → Anwendungen, Berechtigung „repository: Lesen und +Schreiben“) aus ``GITEA_TOKEN`` oder ``~/.config/netmover/gitea-token`` (Modus 0600). +Server und Repository aus ``GITEA_URL``/``GITEA_REPO``, sonst aus ``git remote origin``. +In Gitea Actions setzt der Workflow beides selbst (siehe ``.gitea/workflows/ci.yml``). +Nur Standardbibliothek – läuft ohne venv. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TOKEN_FILE = Path.home() / ".config" / "netmover" / "gitea-token" +TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") + + +def changelog_section(text: str, version: str) -> str: + """Abschnitt ``## [version] – datum`` bis zum nächsten ``## [``, ohne Überschrift.""" + m = re.search( + rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|^\[[^\]]+\]: |\Z)", + text, + re.M | re.S, + ) + if m is None: + raise SystemExit(f"CHANGELOG.md enthält keinen Abschnitt [{version}]") + return m.group(1).strip() + + +def _git(*args: str) -> str: + return subprocess.run( + ["git", *args], # noqa: S607 + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def release_title(tag: str) -> str: + """Betreff der Tag-Annotation, z. B. „NetMover 0.11.0 – mehrere Ziele je Regel“.""" + return _git("tag", "-l", "--format=%(contents:subject)", tag) or f"NetMover {tag[1:]}" + + +def remote() -> tuple[str, str]: + url, repo = os.environ.get("GITEA_URL"), os.environ.get("GITEA_REPO") + if url and repo: + return url.rstrip("/"), repo + origin = _git("config", "--get", "remote.origin.url") + m = re.match(r"^(https?://[^/]+)/(.+?)(\.git)?$", origin) + if m is None: + raise SystemExit(f"Kann Server/Repository nicht aus {origin!r} ableiten") + return m.group(1), m.group(2) + + +def token() -> str: + value = os.environ.get("GITEA_TOKEN", "").strip() + if not value and TOKEN_FILE.is_file(): + if TOKEN_FILE.stat().st_mode & 0o077: + raise SystemExit(f"{TOKEN_FILE} ist für andere lesbar – chmod 600") + value = TOKEN_FILE.read_text().strip() + if not value: + raise SystemExit(f"Kein Token: GITEA_TOKEN setzen oder {TOKEN_FILE} anlegen") + return value + + +def _api(method: str, url: str, tok: str, body: dict[str, object] | None = None) -> object: + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) # noqa: S310 – https-URL + req.add_header("Authorization", f"token {tok}") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + return json.loads(resp.read() or b"null") + + +def publish(tag: str, base: str, repo: str, tok: str, changelog: str) -> str: + payload: dict[str, object] = { + "tag_name": tag, + "name": release_title(tag), + "body": changelog_section(changelog, tag[1:]), + "draft": False, + "prerelease": False, + } + api = f"{base}/api/v1/repos/{repo}/releases" + try: + existing = _api("GET", f"{api}/tags/{tag}", tok) + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + _api("POST", api, tok, payload) + return "angelegt" + if not isinstance(existing, dict): + raise SystemExit(f"Unerwartete Antwort für {tag}: {existing!r}") + _api("PATCH", f"{api}/{existing['id']}", tok, payload) + return "aktualisiert" + + +def main(argv: list[str]) -> int: + if not argv: + print(__doc__) + return 2 + tags = ( + [t for t in _git("tag", "-l", "--sort=v:refname").splitlines() if TAG_RE.match(t)] + if argv == ["--all"] + else argv + ) + changelog = (ROOT / "CHANGELOG.md").read_text() + for tag in tags: # zuerst alles prüfen, dann veröffentlichen + if not TAG_RE.match(tag): + raise SystemExit(f"Kein Versions-Tag: {tag}") + changelog_section(changelog, tag[1:]) + base, repo = remote() + tok = token() + for tag in tags: + try: + what = publish(tag, base, repo, tok, changelog) + except urllib.error.HTTPError as exc: + print(f"{tag}: Fehler {exc.code} {exc.reason}", file=sys.stderr) + return 1 + print(f"{tag}: Release {what} – {base}/{repo}/releases/tag/{tag}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_release_script.py b/tests/test_release_script.py new file mode 100644 index 0000000..b58f2b6 --- /dev/null +++ b/tests/test_release_script.py @@ -0,0 +1,41 @@ +"""scripts/gitea_release.py: Release-Text aus dem Changelog.""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _script() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "gitea_release", ROOT / "scripts/gitea_release.py" + ) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_every_changelog_version_has_notes() -> None: + rel = _script() + text = (ROOT / "CHANGELOG.md").read_text() + versions = re.findall(r"^## \[(\d+\.\d+\.\d+)\]", text, re.M) + assert "0.1.0" in versions + for v in versions: + notes = rel.changelog_section(text, v) + assert notes and "## [" not in notes and not notes.startswith("[") + + +def test_section_boundaries() -> None: + rel = _script() + text = "## [Unreleased]\n\n## [0.2.0] – x\n\n- neu\n\n## [0.1.0] – y\n\n- alt\n\n[0.2.0]: https://e\n" + assert rel.changelog_section(text, "0.2.0") == "- neu" + assert rel.changelog_section(text, "0.1.0") == "- alt" + with pytest.raises(SystemExit): + rel.changelog_section(text, "9.9.9")