312 lines
14 KiB
Python
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}")
|