Files
GiteaMailTemplates/.github/scripts/track_gitea_release.py
T
KenanZhu 23f0456622
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
chore: streamline compatibility tracking and documentation
2026-10-09 11:43:02 +08:00

190 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Record a new upstream Gitea release without changing verified compatibility."""
import argparse
import json
import os
import re
import sys
from datetime import date
from pathlib import Path
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[2]
RELEASES_API = "https://api.github.com/repos/go-gitea/gitea/releases"
VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)\Z")
ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(\d+\.\d+\.\d+)(?:\*\*)?\s*\|")
INLINE_VERSION_RE = re.compile(r"(?<!\d)\d+\.\d+\.\d+(?!\d)")
STATUS_RE = re.compile(r"\[(?:PENDING|PASS|WARN|FAIL|UNSUPPORTED)\]")
MARKER_RE = re.compile(r"<!-- TRACKER:([A-Z][A-Z0-9-]*) -->")
BADGE_RE = re.compile(r"Gitea-(\d+\.\d+\.\d+)(?:%20(?:pending|%5BPENDING%5D))?%20%7C%20(\d+\.\d+\.\d+)%20tested-(?:blue|yellow)")
TABLES = {
"VERSION-MAP": "| Gitea version | Template release |",
"HISTORY": "| Gitea | Release Date | Mail Template Changes | Breaking? |",
}
AUTO_MARKERS = frozenset((*TABLES, "BADGE", "UPSTREAM"))
MANUAL_MARKERS = frozenset(("LATEST-TESTED", "LATEST-VERIFIED"))
REQUIRED_MARKERS = {"VERSION-MAP": 1, "HISTORY": 1, "BADGE": 1, "UPSTREAM": 3, "LATEST-TESTED": 2, "LATEST-VERIFIED": 1}
def version_key(value):
match = VERSION_RE.fullmatch(value)
if not match:
raise ValueError(f"Expected a stable X.Y.Z version, got {value!r}")
return tuple(int(part) for part in match.groups())
def fetch_release(requested_version):
endpoint = f"{RELEASES_API}/tags/v{requested_version}" if requested_version else f"{RELEASES_API}/latest"
request = Request(endpoint, headers={"Accept": "application/vnd.github+json", "User-Agent": "gitea-mail-templates-tracker"})
token = os.environ.get("GITHUB_TOKEN")
if token:
request.add_header("Authorization", f"Bearer {token}")
with urlopen(request, timeout=20) as response:
release = json.load(response)
if release.get("draft") or release.get("prerelease"):
raise ValueError("Draft and prerelease Gitea versions are not tracked")
version = str(release["tag_name"]).removeprefix("v")
version_key(version)
if requested_version and version != requested_version:
raise ValueError(f"Release tag mismatch: expected {requested_version}, got {version}")
published = release.get("published_at")
if not published:
raise ValueError(f"Release v{version} has no published_at date")
release_date = date.fromisoformat(published[:10]).isoformat()
return version, release_date
def table(lines, marker_index, expected_header):
header = marker_index + 1
if header + 1 >= len(lines) or lines[header].strip() != expected_header:
raise ValueError(f"Unexpected table header after {lines[marker_index].strip()}")
if not re.fullmatch(r"\|[\s|:-]+\|", lines[header + 1].strip()):
raise ValueError(f"Missing Markdown table separator after {lines[marker_index].strip()}")
first = header + 2
end = first
while end < len(lines) and lines[end].startswith("|"):
end += 1
versions = set()
for line in lines[first:end]:
match = ROW_RE.match(line)
if not match:
if expected_header == TABLES["HISTORY"] and re.match(r"^\|\s*\*\*≤\s*\d+\.\d+\.x\*\*\s*\|", line):
continue
raise ValueError(f"Malformed version row after {lines[marker_index].strip()}: {line.strip()}")
if match.group(1) in versions:
raise ValueError(f"Duplicate version after {lines[marker_index].strip()}: {match.group(1)}")
versions.add(match.group(1))
if not versions:
raise ValueError(f"Empty version table after {lines[marker_index].strip()}")
return first, versions
def documents(root):
"""Discover Markdown docs; new docs opt in by adding a known TRACKER marker."""
paths = sorted((*root.glob("*.md"), *root.glob("docs/**/*.md")))
result = {path: path.read_text(encoding="utf-8").splitlines(keepends=True) for path in paths}
markers = []
for path, lines in result.items():
for index, line in enumerate(lines):
for match in MARKER_RE.finditer(line):
name = match.group(1)
if name not in AUTO_MARKERS | MANUAL_MARKERS:
raise ValueError(f"Unknown TRACKER marker {name} in {path}")
markers.append((path, index, name))
for name, minimum in REQUIRED_MARKERS.items():
count = sum(item[2] == name for item in markers)
if count < minimum or (name in TABLES or name == "BADGE") and count != minimum:
raise ValueError(f"Expected {minimum} TRACKER:{name} marker(s), found {count}")
return result, markers
def latest_tested(documents_by_path, markers):
versions = set()
for path, index, name in markers:
if name == "LATEST-TESTED":
match = re.search(r"Gitea\s+(\d+\.\d+\.\d+)", documents_by_path[path][index])
if not match:
raise ValueError(f"Missing Gitea version beside TRACKER:LATEST-TESTED in {path}")
versions.add(match.group(1))
if len(versions) != 1:
raise ValueError(f"LATEST-TESTED markers disagree: {sorted(versions)}")
return versions.pop()
def update_marker(lines, index, name, version, release_date, tested):
if name in TABLES:
first, versions = table(lines, index, TABLES[name])
if version not in versions:
if name == "VERSION-MAP":
row = f"| {version} | [PENDING] Compatibility verification; no tested template release yet |\n"
else:
row = f"| **{version}** | {release_date} | [PENDING] Mail-template compatibility verification | TBD |\n"
lines.insert(first, row)
elif name == "UPSTREAM":
versions = list(INLINE_VERSION_RE.finditer(lines[index]))
if len(STATUS_RE.findall(lines[index])) != 1 or len(versions) != 1:
raise ValueError("TRACKER:UPSTREAM needs exactly one status and version on the same line")
line = INLINE_VERSION_RE.sub(version, lines[index], count=1)
lines[index] = STATUS_RE.sub("[PENDING]", line, count=1)
elif name == "BADGE":
match = BADGE_RE.search(lines[index])
if not match or match.group(2) != tested:
raise ValueError("TRACKER:BADGE has an unexpected format or tested version")
badge = f"Gitea-{version}%20%5BPENDING%5D%20%7C%20{tested}%20tested-yellow"
lines[index] = BADGE_RE.sub(badge, lines[index], count=1)
def apply_release(root, version, release_date, dry_run=False):
target = version_key(version)
date.fromisoformat(release_date)
originals, markers = documents(root)
tested = latest_tested(originals, markers)
history_path, history_index, _ = next(item for item in markers if item[2] == "HISTORY")
history_first, _ = table(originals[history_path], history_index, TABLES["HISTORY"])
latest = version_key(ROW_RE.match(originals[history_path][history_first]).group(1))
if target < latest or target <= version_key(tested):
return []
updated = {path: lines.copy() for path, lines in originals.items()}
for path, index, name in sorted(markers, key=lambda item: (str(item[0]), item[1]), reverse=True):
if name in AUTO_MARKERS:
update_marker(updated[path], index, name, version, release_date, tested)
changed = [path for path in updated if updated[path] != originals[path]]
if not dry_run:
for path in changed:
path.write_text("".join(updated[path]), encoding="utf-8")
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", default=os.environ.get("GITEA_VERSION"), help="Specific stable Gitea version; omit for latest release")
parser.add_argument("--release-date", help="YYYY-MM-DD; use with --version for offline checks")
parser.add_argument("--dry-run", action="store_true", help="Check without changing repository documentation")
args = parser.parse_args()
if args.release_date and not args.version:
parser.error("--release-date requires --version")
if args.version:
args.version = args.version.removeprefix("v")
version_key(args.version)
if args.release_date:
version, release_date = args.version, date.fromisoformat(args.release_date).isoformat()
else:
version, release_date = fetch_release(args.version)
changed = apply_release(ROOT, version, release_date, dry_run=args.dry_run)
print(f"Gitea {version}: {', '.join(str(path.relative_to(ROOT)) for path in changed) if changed else 'already tracked'}")
output_path = os.environ.get("GITHUB_OUTPUT")
if output_path:
with open(output_path, "a", encoding="utf-8") as output:
output.write(f"version={version}\nchanged={'true' if changed else 'false'}\n")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (KeyError, OSError, ValueError) as error:
sys.exit(f"Tracker failed: {error}")