Files
GiteaMailTemplates/.github/scripts/track_gitea_release.py
T
KenanZhu 5c0589f6f6
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s
docs: clarify per-version Gitea compatibility
2026-10-09 12:27:00 +08:00

312 lines
14 KiB
Python

#!/usr/bin/env python3
"""Update marker-driven documentation for upstream and template releases."""
import argparse
import json
import os
import re
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
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)\]")
MANIFEST_RE = re.compile(r"^<!-- DOC-TAGS: (\{.*\}) -->\s*$")
TAG_RE = re.compile(r"^<!-- (/?)([A-Z][A-Z0-9-]*):([A-Z][A-Z0-9-]*) -->\s*$")
BADGE_RE = re.compile(r"Gitea-(\d+\.\d+\.\d+)(?:%20(?:pending|%5BPENDING%5D))?%20%7C%20(\d+\.\d+\.\d+)%20tested-(?:blue|yellow)")
RELEASE_VERSION_RE = re.compile(r"\bv\d+\.\d+\.\d+\b")
RELEASE_LINK_RE = re.compile(r"\[v\d+\.\d+\.\d+\]\(https://[^)]+/releases/latest\)")
TABLES = {
"VERSION-MAP": "| Gitea version | Recommended template release | Status | Notes |",
"HISTORY": "| Gitea | Release Date | Mail Template Changes | Impact |",
}
SKIP_DIRECTORIES = frozenset((".git", "node_modules", "vendor", "dist"))
class TagBlock(NamedTuple):
path: Path
subject: str
content: str
start: int
end: int
def body(lines, block):
return lines[block.start + 1:block.end]
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, name):
if len(lines) < 3 or lines[0].strip() != TABLES[name]:
raise ValueError(f"Unexpected table header in TRACKER:{name}")
if not re.fullmatch(r"\|[\s|:-]+\|", lines[1].strip()):
raise ValueError(f"Missing Markdown table separator in TRACKER:{name}")
if any(not line.startswith("|") for line in lines[2:]):
raise ValueError(f"Non-table line in TRACKER:{name}")
versions = set()
for line in lines[2:]:
match = ROW_RE.match(line)
if not match:
if name == "HISTORY" and re.match(r"^\|\s*\*\*≤\s*\d+\.\d+\.x\*\*\s*\|", line):
continue
raise ValueError(f"Malformed version row in TRACKER:{name}: {line.strip()}")
if match.group(1) in versions:
raise ValueError(f"Duplicate version in TRACKER:{name}: {match.group(1)}")
versions.add(match.group(1))
if not versions:
raise ValueError(f"Empty version table in TRACKER:{name}")
return versions
def documents(root):
"""Scan every Markdown file; each participant declares its paired tag blocks."""
result = {}
blocks = []
for path in sorted(root.rglob("*.md")):
if any(part in SKIP_DIRECTORIES or part.startswith("tracker-test-") for part in path.relative_to(root).parts[:-1]):
continue
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
declarations = [MANIFEST_RE.match(line) for line in lines]
declarations = [match for match in declarations if match]
if len(declarations) > 1:
raise ValueError(f"Multiple DOC-TAGS declarations in {path}")
if not declarations:
if any(TAG_RE.match(line) for line in lines):
raise ValueError(f"Tag block without DOC-TAGS declaration in {path}")
continue
manifest = json.loads(declarations[0].group(1))
if not isinstance(manifest, dict) or not manifest:
raise ValueError(f"DOC-TAGS must be a non-empty object in {path}")
declared = set()
for subject, contents in manifest.items():
if subject not in HANDLERS or not isinstance(contents, list) or not contents:
raise ValueError(f"Unknown or empty DOC-TAGS subject {subject!r} in {path}")
for content in contents:
if content not in HANDLERS[subject]:
raise ValueError(f"Unknown {subject} content {content!r} in {path}")
declared.add((subject, content))
result[path] = lines
opened = None
found = set()
for index, line in enumerate(lines):
match = TAG_RE.match(line)
if not match:
continue
closing, subject, content = match.groups()
key = (subject, content)
if key not in declared:
raise ValueError(f"Undeclared {subject}:{content} block in {path}")
if closing:
if opened is None or opened[0] != key:
raise ValueError(f"Unmatched closing tag {subject}:{content} in {path}")
if key not in found:
blocks.append(TagBlock(path, subject, content, opened[1], index))
found.add(key)
opened = None
elif opened is not None:
raise ValueError(f"Nested tag block in {path}")
else:
opened = (key, index)
if opened is not None:
raise ValueError(f"Unclosed tag block {opened[0]} in {path}")
if missing := declared - found:
raise ValueError(f"Missing declared tag block(s) {sorted(missing)} in {path}")
return result, blocks
def latest_tested(documents_by_path, blocks):
versions = set()
for block in blocks:
if (block.subject, block.content) == ("TRACKER", "LATEST-TESTED"):
match = re.search(r"Gitea\s+(\d+\.\d+\.\d+)", "".join(body(documents_by_path[block.path], block)))
if not match:
raise ValueError(f"Missing Gitea version in TRACKER:LATEST-TESTED in {block.path}")
versions.add(match.group(1))
if len(versions) != 1:
raise ValueError(f"LATEST-TESTED markers disagree: {sorted(versions)}")
return versions.pop()
def update_version_map(content, version, release_date, tested):
if version not in table(content, "VERSION-MAP"):
content.insert(2, f"| {version} | — | [PENDING] | Compatibility verification pending |\n")
return content
def update_history(content, version, release_date, tested):
if version not in table(content, "HISTORY"):
content.insert(2, f"| **{version}** | {release_date} | [PENDING] Review of mail-template changes | TBD |\n")
return content
def update_upstream(content, version, release_date, tested):
if len(content) != 1 or len(STATUS_RE.findall(content[0])) != 1 or len(INLINE_VERSION_RE.findall(content[0])) != 1:
raise ValueError("TRACKER:UPSTREAM needs exactly one status and version on one body line")
line = INLINE_VERSION_RE.sub(version, content[0], count=1)
content[0] = STATUS_RE.sub("[PENDING]", line, count=1)
return content
def update_badge(content, version, release_date, tested):
match = BADGE_RE.search(content[0]) if len(content) == 1 else None
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"
content[0] = BADGE_RE.sub(badge, content[0], count=1)
return content
def keep_manual(content, version, release_date, tested):
return content
def update_release(content, tag, path):
line = "".join(content)
matches = RELEASE_VERSION_RE.findall(line)
if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1):
raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {path}")
return [RELEASE_VERSION_RE.sub(tag, content[0], count=1)]
HANDLERS = {
"TRACKER": {
"VERSION-MAP": update_version_map,
"HISTORY": update_history,
"UPSTREAM": update_upstream,
"BADGE": update_badge,
"LATEST-TESTED": keep_manual,
"LATEST-VERIFIED": keep_manual,
},
"RELEASE": {"HEADER": update_release, "SUMMARY": update_release, "CURRENT": update_release},
}
def dispatch(originals, blocks, subject, handler):
updated = {path: lines.copy() for path, lines in originals.items()}
for block in sorted((item for item in blocks if item.subject == subject), key=lambda item: (str(item.path), item.start), reverse=True):
lines = updated[block.path]
lines[block.start + 1:block.end] = handler(block, body(lines, block))
return updated
def save_changes(originals, updated, dry_run):
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 apply_release(root, version, release_date, dry_run=False):
target = version_key(version)
date.fromisoformat(release_date)
originals, blocks = documents(root)
tested = latest_tested(originals, blocks)
history = [block for block in blocks if (block.subject, block.content) == ("TRACKER", "HISTORY")]
if len(history) != 1:
raise ValueError(f"Expected one TRACKER:HISTORY block, found {len(history)}")
history_lines = body(originals[history[0].path], history[0])
latest = max(map(version_key, table(history_lines, "HISTORY")))
if target < latest or target <= version_key(tested):
return []
updated = dispatch(originals, blocks, "TRACKER", lambda block, content: HANDLERS["TRACKER"][block.content](content, version, release_date, tested))
return save_changes(originals, updated, dry_run)
def apply_template_release(root, tag, dry_run=False):
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
raise ValueError(f"Expected a stable vX.Y.Z release tag, got {tag!r}")
originals, blocks = documents(root)
release_blocks = [block for block in blocks if block.subject == "RELEASE"]
if not release_blocks:
raise ValueError("No RELEASE blocks declared")
versions = set()
for block in release_blocks:
content = body(originals[block.path], block)
line = "".join(content)
matches = RELEASE_VERSION_RE.findall(line)
if len(content) != 1 or len(matches) != 1 or ("[v" in line and len(RELEASE_LINK_RE.findall(line)) != 1):
raise ValueError(f"RELEASE block needs one vX.Y.Z label and a /releases/latest link when linked: {block.path}")
versions.add(matches[0])
if len(versions) != 1:
raise ValueError(f"TRACKER:RELEASE labels disagree: {sorted(versions)}")
current = versions.pop()
if version_key(tag) <= version_key(current):
return []
updated = dispatch(originals, blocks, "RELEASE", lambda block, content: HANDLERS["RELEASE"][block.content](content, tag, block.path))
return save_changes(originals, updated, dry_run)
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("--template-release", help="Published template tag vX.Y.Z; update RELEASE blocks only")
parser.add_argument("--dry-run", action="store_true", help="Check without changing repository documentation")
args = parser.parse_args()
if args.template_release and (args.version or args.release_date):
parser.error("--template-release cannot be combined with upstream release options")
if args.release_date and not args.version:
parser.error("--release-date requires --version")
if args.template_release:
version = args.template_release
changed = apply_template_release(ROOT, version, dry_run=args.dry_run)
label = "Template release"
else:
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)
label = "Gitea"
print(f"{label} {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}")