190 lines
8.9 KiB
Python
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}")
|