chore: Gitea-Releases aus dem Changelog – Skript und CI-Job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fe64f031bd
commit
a627044df7
@@ -35,3 +35,20 @@ jobs:
|
|||||||
run: shellcheck deploy/*.sh
|
run: shellcheck deploy/*.sh
|
||||||
- name: pytest (inkl. Migrationen auf befüllter Datenbank)
|
- name: pytest (inkl. Migrationen auf befüllter Datenbank)
|
||||||
run: pytest -q
|
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 }}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.11.0] – 2026-10-24
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -472,7 +472,10 @@ registriert.
|
|||||||
[CHANGELOG.md](CHANGELOG.md). Bei einem Release:
|
[CHANGELOG.md](CHANGELOG.md). Bei einem Release:
|
||||||
1. CI grün? Dann den Abschnitt `[Unreleased]` mit Versionsnummer versehen.
|
1. CI grün? Dann den Abschnitt `[Unreleased]` mit Versionsnummer versehen.
|
||||||
2. Die Version in `src/netmover/__init__.py` anheben.
|
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).
|
- Arbeitszeit und Entscheidungen stehen in [DEVLOG.md](DEVLOG.md).
|
||||||
- htmx 2.0.4 liegt unter `src/netmover/web/static/htmx.min.js`
|
- htmx 2.0.4 liegt unter `src/netmover/web/static/htmx.min.js`
|
||||||
(SHA-256 `e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`).
|
(SHA-256 `e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447`).
|
||||||
|
|||||||
Executable
+138
@@ -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:]))
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user