refactor: unify markdown release tracking blocks
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 12:10:12 +08:00
1 parent 23f0456622
commit 14dc37046d
8 files changed
+467 -114

No files matched your search

+152 -7
View File
@@ -13,6 +13,73 @@ SCRIPT = Path(__file__).with_name("track_gitea_release.py")
SPEC = importlib.util.spec_from_file_location("track_gitea_release", SCRIPT)
TRACKER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(TRACKER)
RELEASE_URL = "https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest"
FIXTURES = {
"COMPATIBILITY.md": """# Gitea Compatibility
<!-- DOC-TAGS: {"TRACKER":["LATEST-VERIFIED","VERSION-MAP","HISTORY"]} -->
| Template Release | Min Gitea | Max Tested Gitea | Status |
|---|---|---|---|
| **v28.0.0** | **28.0.0** | **28.0.0** | [PASS] Active |
<!-- TRACKER:LATEST-VERIFIED -->
> **Latest verified:** Release v28.0.0 passes tests.
<!-- /TRACKER:LATEST-VERIFIED -->
<!-- TRACKER:VERSION-MAP -->
| Gitea version | Template release |
|---|---|
| 28.1.0 | [PENDING] Compatibility verification; no tested template release yet |
| 28.0.0 | **v28.0.0** |
<!-- /TRACKER:VERSION-MAP -->
<!-- TRACKER:HISTORY -->
| Gitea | Release Date | Mail Template Changes | Breaking? |
|---|---|---|---|
| **28.1.0** | 2026-10-06 | [PENDING] Mail-template compatibility verification | TBD |
| **28.0.0** | 2026-09-29 | FormatByteSize | Yes |
| **≤ 1.24.x** | — | Old directory | [UNSUPPORTED] |
<!-- /TRACKER:HISTORY -->
""",
"README.md": f"""# Gitea Mail Templates
<!-- DOC-TAGS: {{"TRACKER":["BADGE","LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]}} -->
<!-- RELEASE:HEADER -->
> Latest Release: [v28.0.0]({RELEASE_URL})
<!-- /RELEASE:HEADER -->
<!-- TRACKER:BADGE -->
[![Gitea](https://img.shields.io/badge/Gitea-28.1.0%20%5BPENDING%5D%20%7C%2028.0.0%20tested-yellow)](COMPATIBILITY.md)
<!-- /TRACKER:BADGE -->
<!-- TRACKER:LATEST-TESTED -->
- **Latest tested:** Gitea 28.0.0
<!-- /TRACKER:LATEST-TESTED -->
<!-- RELEASE:SUMMARY -->
- **Latest release:** [v28.0.0]({RELEASE_URL})
<!-- /RELEASE:SUMMARY -->
<!-- TRACKER:UPSTREAM -->
- **Upstream Gitea 28.1.0:** [PENDING]
<!-- /TRACKER:UPSTREAM -->
""",
"AGENTS.md": """# Agents
<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"],"RELEASE":["CURRENT"]} -->
<!-- RELEASE:CURRENT -->
- Current template release: **v28.0.0**.
<!-- /RELEASE:CURRENT -->
<!-- TRACKER:UPSTREAM -->
- Latest upstream Gitea release: 28.1.0 [PENDING].
<!-- /TRACKER:UPSTREAM -->
""",
"docs/README.zh-CN.md": f"""# Gitea 邮件模板
<!-- DOC-TAGS: {{"TRACKER":["LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]}} -->
<!-- RELEASE:HEADER -->
> 最新发布版:[v28.0.0]({RELEASE_URL})
<!-- /RELEASE:HEADER -->
<!-- TRACKER:LATEST-TESTED -->
- **最新测试:** Gitea 28.0.0
<!-- /TRACKER:LATEST-TESTED -->
<!-- RELEASE:SUMMARY -->
- **最新发布版:** [v28.0.0]({RELEASE_URL})
<!-- /RELEASE:SUMMARY -->
<!-- TRACKER:UPSTREAM -->
- **上游 Gitea 28.1.0:** [PENDING]
<!-- /TRACKER:UPSTREAM -->
""",
}
class TrackerTests(unittest.TestCase):
@@ -25,7 +92,7 @@ class TrackerTests(unittest.TestCase):
for name in self.paths:
destination = self.root / name
destination.parent.mkdir(parents=True, exist_ok=True)
content = (TRACKER.ROOT / name).read_text(encoding="utf-8")
content = FIXTURES[name]
destination.write_text(content, encoding="utf-8")
self.originals[name] = content
@@ -52,6 +119,13 @@ class TrackerTests(unittest.TestCase):
self.assertIn("最新测试:** Gitea 28.0.0", self.read("docs/README.zh-CN.md"))
self.assertEqual([], TRACKER.apply_release(self.root, "28.2.0", "2026-11-01"))
def test_repository_markers_are_valid(self):
documents, blocks = TRACKER.documents(TRACKER.ROOT)
self.assertTrue({TRACKER.ROOT / name for name in self.paths}.issubset(documents))
self.assertIn(("TRACKER", "HISTORY"), {(block.subject, block.content) for block in blocks})
self.assertIn(("RELEASE", "HEADER"), {(block.subject, block.content) for block in blocks})
TRACKER.latest_tested(documents, blocks)
def test_existing_version_is_idempotent(self):
self.assertEqual([], TRACKER.apply_release(self.root, "28.1.0", "2026-10-06"))
self.assert_unchanged()
@@ -73,24 +147,64 @@ class TrackerTests(unittest.TestCase):
def test_missing_marker_fails_without_writing(self):
compatibility = self.root / "COMPATIBILITY.md"
compatibility.write_text(self.read("COMPATIBILITY.md").replace("<!-- TRACKER:HISTORY -->", ""), encoding="utf-8")
compatibility.write_text(self.read("COMPATIBILITY.md").replace("<!-- TRACKER:HISTORY -->\n", ""), encoding="utf-8")
with self.assertRaises(ValueError):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertEqual(self.originals["README.md"], self.read("README.md"))
def test_unknown_marker_fails_without_writing(self):
news = self.root / "docs/NEWS.md"
news.write_text("Gitea 28.1.0 [PENDING] <!-- TRACKER:FUTURE-UNKNOWN -->\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Unknown TRACKER marker"):
news.write_text('<!-- DOC-TAGS: {"TRACKER":["FUTURE-UNKNOWN"]} -->\n', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Unknown TRACKER content"):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assert_unchanged()
def test_new_marked_document_is_discovered(self):
news = self.root / "docs/NEWS.md"
news.write_text("Gitea 28.1.0 [PENDING] <!-- TRACKER:UPSTREAM -->\n", encoding="utf-8")
news = self.root / "misc/notes/NEWS.md"
news.parent.mkdir(parents=True)
news.write_text('<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"]} -->\n<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n', encoding="utf-8")
changed = TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertIn(news, changed)
self.assertEqual("Gitea 28.2.0 [PENDING] <!-- TRACKER:UPSTREAM -->\n", news.read_text(encoding="utf-8"))
self.assertIn("Gitea 28.2.0 [PENDING]", news.read_text(encoding="utf-8"))
def test_duplicate_block_uses_first_in_document_order(self):
english = self.root / "README.md"
duplicate = "<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n"
english.write_text(self.read("README.md") + duplicate, encoding="utf-8")
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertIn("**Upstream Gitea 28.2.0:** [PENDING]", self.read("README.md"))
self.assertTrue(self.read("README.md").endswith(duplicate))
def test_unclosed_block_rejected_without_writing(self):
english = self.root / "README.md"
english.write_text(self.read("README.md").replace("<!-- /TRACKER:UPSTREAM -->", ""), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Unclosed tag block"):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertEqual(self.originals["COMPATIBILITY.md"], self.read("COMPATIBILITY.md"))
def test_undeclared_block_rejected(self):
english = self.root / "README.md"
english.write_text(self.read("README.md") + "<!-- RELEASE:CURRENT -->\nv28.0.0\n<!-- /RELEASE:CURRENT -->\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Undeclared RELEASE:CURRENT"):
TRACKER.apply_template_release(self.root, "v28.0.1")
def test_misspelled_subject_rejected(self):
english = self.root / "README.md"
english.write_text(self.read("README.md").replace("<!-- TRACKER:UPSTREAM -->", "<!-- TRACER:UPSTREAM -->"), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Undeclared TRACER:UPSTREAM"):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
def test_block_without_manifest_rejected(self):
news = self.root / "NEWS.md"
news.write_text("<!-- TRACKER:UPSTREAM -->\nGitea 28.1.0 [PENDING]\n<!-- /TRACKER:UPSTREAM -->\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "without DOC-TAGS"):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
def test_mismatched_close_rejected(self):
english = self.root / "README.md"
english.write_text(self.read("README.md").replace("<!-- /RELEASE:SUMMARY -->", "<!-- /RELEASE:HEADER -->"), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Unmatched closing tag"):
TRACKER.apply_template_release(self.root, "v28.0.1")
def test_disagreeing_tested_versions_fail(self):
chinese = self.root / "docs/README.zh-CN.md"
@@ -120,6 +234,37 @@ class TrackerTests(unittest.TestCase):
with self.assertRaises(ValueError):
TRACKER.apply_release(self.root, "28.2.0-rc1", "2026-11-01")
def test_template_release_updates_all_marked_labels(self):
changed = TRACKER.apply_template_release(self.root, "v28.0.1")
self.assertEqual({self.root / name for name in ("README.md", "docs/README.zh-CN.md", "AGENTS.md")}, set(changed))
for name in ("README.md", "docs/README.zh-CN.md"):
self.assertEqual(2, self.read(name).count("[v28.0.1](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)"))
self.assertNotIn("/releases/tag/v28.0.1", self.read(name))
self.assertIn("Current template release: **v28.0.1**", self.read("AGENTS.md"))
self.assertEqual(self.originals["COMPATIBILITY.md"], self.read("COMPATIBILITY.md"))
self.assertEqual([], TRACKER.apply_template_release(self.root, "v28.0.1"))
self.assertEqual([], TRACKER.apply_template_release(self.root, "v28.0.0"))
def test_template_release_dry_run_does_not_write(self):
self.assertEqual(3, len(TRACKER.apply_template_release(self.root, "v28.0.1", dry_run=True)))
self.assert_unchanged()
def test_template_release_rejects_mismatched_labels(self):
english = self.root / "README.md"
english.write_text(self.read("README.md").replace("Latest Release: [v28.0.0]", "Latest Release: [v28.0.1]"), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "disagree"):
TRACKER.apply_template_release(self.root, "v28.0.2")
def test_template_release_rejects_tagged_link(self):
english = self.root / "README.md"
english.write_text(self.read("README.md").replace("[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)", "[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0)", 1), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "/releases/latest"):
TRACKER.apply_template_release(self.root, "v28.0.1")
def test_template_release_rejects_invalid_tag(self):
with self.assertRaises(ValueError):
TRACKER.apply_template_release(self.root, "v28.0.1-rc1")
def test_release_api_uses_published_date(self):
payload = {"tag_name": "v28.2.0", "published_at": "2026-11-01T08:15:00Z", "draft": False, "prerelease": False}
with patch.object(TRACKER, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode("utf-8"))) as mocked:
+208 -86
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Record a new upstream Gitea release without changing verified compatibility."""
"""Update marker-driven documentation for upstream and template releases."""
import argparse
import json
@@ -8,6 +8,7 @@ import re
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
from urllib.request import Request, urlopen
@@ -17,15 +18,28 @@ 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-]*) -->")
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 | 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}
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):
@@ -56,101 +70,160 @@ def fetch_release(requested_version):
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
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[first:end]:
for line in lines[2:]:
match = ROW_RE.match(line)
if not match:
if expected_header == TABLES["HISTORY"] and re.match(r"^\|\s*\*\*≤\s*\d+\.\d+\.x\*\*\s*\|", line):
if name == "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()}")
raise ValueError(f"Malformed version row in TRACKER:{name}: {line.strip()}")
if match.group(1) in versions:
raise ValueError(f"Duplicate version after {lines[marker_index].strip()}: {match.group(1)}")
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 after {lines[marker_index].strip()}")
return first, versions
raise ValueError(f"Empty version table in TRACKER:{name}")
return 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():
"""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):
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])
match = TAG_RE.match(line)
if not match:
raise ValueError(f"Missing Gitea version beside TRACKER:LATEST-TESTED in {path}")
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_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 update_version_map(content, version, release_date, tested):
if version not in table(content, "VERSION-MAP"):
content.insert(2, f"| {version} | [PENDING] Compatibility verification; no tested template release yet |\n")
return content
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 []
def update_history(content, version, release_date, tested):
if version not in table(content, "HISTORY"):
content.insert(2, f"| **{version}** | {release_date} | [PENDING] Mail-template compatibility verification | 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 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)
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:
@@ -158,23 +231,72 @@ def apply_release(root, version, release_date, dry_run=False):
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.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()
if args.template_release:
version = args.template_release
changed = apply_template_release(ROOT, version, dry_run=args.dry_run)
label = "Template release"
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'}")
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:
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
if: steps.version.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
add-paths: '*.md'
add-paths: '**/*.md'
branch: track/gitea-${{ steps.version.outputs.version }}
commit-message: "docs: track Gitea ${{ steps.version.outputs.version }} pending verification"
title: "Track Gitea ${{ steps.version.outputs.version }} compatibility"
+56 -2
View File
@@ -16,6 +16,13 @@ jobs:
with:
go-version: '1.21'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test documentation tracker
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
- name: Test — mail template data contexts
working-directory: tools
run: go test ./...
@@ -33,14 +40,25 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-go@v5
with:
go-version: '1.21'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify release notes
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
- name: Update release labels in the archive
run: python -B .github/scripts/track_gitea_release.py --template-release "$TEMPLATE_RELEASE"
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}
- name: Generate static preview for the archive
working-directory: tools
run: go run . preview all
@@ -61,8 +79,44 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v3
with:
files: dist/*.zip,dist/*.tar.gz
files: |
dist/*.zip
dist/*.tar.gz
fail_on_unmatched_files: true
body_path: .github/release-notes/${{ github.ref_name }}.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
sync-release-docs:
name: Update Latest Release Documentation
if: startsWith(github.ref, 'refs/tags/v')
needs: package
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Update marked release labels on main
run: python -B .github/scripts/track_gitea_release.py --template-release "$TEMPLATE_RELEASE"
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}
- name: Commit documentation update
run: |
git add -- ':(glob)**/*.md'
if git diff --cached --quiet; then
echo "Release labels are already current."
exit 0
fi
git -c user.name='release-bot' -c user.email='release-bot@users.noreply.local' commit -m "docs: mark ${TEMPLATE_RELEASE} as latest release"
git push origin HEAD:main
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}