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 }}
+10 -4
View File
@@ -1,4 +1,5 @@
# AGENTS.md — Gitea Mail Templates
<!-- DOC-TAGS: {"TRACKER":["UPSTREAM"],"RELEASE":["CURRENT"]} -->
## Project Overview
@@ -71,12 +72,17 @@ docs/ # Bilingual documentation (English + Simplified Chinese)
## Versioning
- Release tags identify actual downloadable packages; the compatibility matrix distinguishes released tags from source-only fixes
- The current release is **v28.0.0**, verified against Gitea 28.0.0. Gitea 28 removes `FileSize` in favor of `FormatByteSize`, so v28.0.0 is not compatible with Gitea 1.25.0–1.27.3; use v1.27.3 for those versions. The quick-reference table in `COMPATIBILITY.md` lists the active release first
- Latest upstream Gitea release: 28.1.0 [PENDING]. <!-- TRACKER:UPSTREAM -->
<!-- RELEASE:CURRENT -->
- Current template release: **v28.0.0**.
<!-- /RELEASE:CURRENT -->
- v28.0.0 was verified against Gitea 28.0.0. Gitea 28 removes `FileSize` in favor of `FormatByteSize`, so v28.0.0 is not compatible with Gitea 1.25.0–1.27.3; use v1.27.3 for those versions. The quick-reference table in `COMPATIBILITY.md` lists the active release first
<!-- TRACKER:UPSTREAM -->
- Latest upstream Gitea release: 28.1.0 [PENDING].
<!-- /TRACKER:UPSTREAM -->
- When a new Gitea version appears, the tracker updates pending rows, marked version lines, and the pending README badge. After verification, update the top `COMPATIBILITY.md` tested range and README tested text/badge; keep unreleased fixes distinct from the published release
- Tag a new release (`vX.Y.Z`) only when the template content itself changes. On the new Gitea host, do not assume tag pushes automatically build or upload archives; verify the Gitea workflow before relying on it
- Before tagging, add `.github/release-notes/vX.Y.Z.md`, run `go test ./...` and `go run . preview all` from `tools/`, then build and upload the archives with the reviewed notes. `.github/workflows/release.yml` retains the earlier GitHub Actions flow as a reference
- Keep `TRACKER:VERSION-MAP` and `TRACKER:HISTORY` above their tables, and inline `TRACKER:BADGE`, `TRACKER:UPSTREAM`, `TRACKER:LATEST-TESTED`, and `TRACKER:LATEST-VERIFIED` markers with their text. The Python tracker updates pending markers; tested/verified markers are manual-only
- Before tagging, add `.github/release-notes/vX.Y.Z.md`, run `go test ./...` and `go run . preview all` from `tools/`. The release workflow packages the tag, publishes the reviewed notes, and then updates `RELEASE` blocks on `main` when the host supports those Actions and write permissions; otherwise build/upload and update the labels manually
- Participating Markdown documents declare their blocks in a `DOC-TAGS` JSON comment. Each block uses a paired opening `<!-- SUBJECT:CONTENT -->` and closing `<!-- /SUBJECT:CONTENT -->` comment; its body is the managed text. `TRACKER` handles upstream-pending content, `RELEASE` handles published-template labels, and `TRACKER:LATEST-TESTED` / `TRACKER:LATEST-VERIFIED` are manual-only. Within a document, the first block for a subject/content pair wins
## Commit Conventions
+9 -4
View File
@@ -1,4 +1,5 @@
# Gitea Compatibility
<!-- DOC-TAGS: {"TRACKER":["LATEST-VERIFIED","VERSION-MAP","HISTORY"]} -->
This document tracks the compatibility between **Gitea Mail Templates** releases and **Gitea** versions.
@@ -9,10 +10,12 @@ This document tracks the compatibility between **Gitea Mail Templates** releases
| **v28.0.0** | **28.0.0** | **28.0.0** | [PASS] Active |
| **v1.27.3** | **1.25.0** | **1.27.3** | [PASS] Superseded; release emails fail on Gitea 28.0.0 (`FileSize` removed) |
| **v1.27.2** | **1.25.0** | **1.27.3** | [WARN] Push notices fail in Bloom, Ember, and Heritage on Gitea 1.27.1+ |
| **v1.0.1** | **1.25.0** | **1.27.0** | [PASS] Superseded; push notices need newer release on 1.27.1+ |
| **v1.0.0** | **1.25.0** | **1.26.4** | [PASS] Superseded |
| **v1.0.1** | **1.25.0** | **1.27.0** | [PASS] Superseded; push notices need newer release on 1.27.1+ |
| **v1.0.0** | **1.25.0** | **1.26.4** | [PASS] Superseded |
> **Latest verified:** Release v28.0.0 passes the Gitea 28.0.0 mail-template, mailer-context, function, and translation-key source audit plus all-theme rendering tests. This release requires Gitea 28.0.0; use v1.27.3 for Gitea 1.25.0–1.27.3. <!-- TRACKER:LATEST-VERIFIED -->
<!-- TRACKER:LATEST-VERIFIED -->
> **Latest verified:** Release v28.0.0 passes the Gitea 28.0.0 mail-template, mailer-context, function, and translation-key source audit plus all-theme rendering tests. This release requires Gitea 28.0.0; use v1.27.3 for Gitea 1.25.0–1.27.3.
<!-- /TRACKER:LATEST-VERIFIED -->
## Versioning
@@ -26,6 +29,7 @@ The release tag identifies the downloadable template package. The supported Gite
| 1.27.3 | **v1.27.3**; v1.27.2 has a push-notification issue in three themes |
| 1.27.2 | **v1.27.3**; v1.27.2 has the same issue |
| 1.27.1 | **v1.27.3**; v1.27.2 has the same issue |
<!-- /TRACKER:VERSION-MAP -->
- The [tracker workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/gitea-tracker.yml) records new upstream versions as [PENDING] in this table, the history below, and the marked README and AGENTS lines. Its automatic PR creation has not been verified on the new Gitea host; check releases manually until Gitea automation is configured.
- After verification, update the top **Template Release** row only when that package has been tested against the new Gitea version. Keep fixes on `main` marked **unreleased** until a new tag and downloadable Gitea Release are published; do not assume a tag push uploads archives automatically.
@@ -59,6 +63,7 @@ gitea --version
| **1.25.5** | 2026-03-10 | None — security + maintenance | No |
| **1.25.0** | 2025 | **Directory restructure** — templates moved to `mail/<category>/<type>.tmpl` (PR #35150); subject/body split with `---` separator; template preview support added | **Yes** (structural) |
| **≤ 1.24.x** | — | Flat directory structure under `custom/templates/mail/` | [UNSUPPORTED] |
<!-- /TRACKER:HISTORY -->
## Template Variable Reference
@@ -117,7 +122,7 @@ All templates use Gitea's official `mail.*` translation namespace. Every referen
## Version Tracking
The [tracker workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/gitea-tracker.yml) scans root-level and `docs/` Markdown for `TRACKER:` markers. `VERSION-MAP` and `HISTORY` add pending rows; `UPSTREAM` updates the latest upstream version and status in English/Chinese README and AGENTS; `BADGE` shows the new version as pending while retaining the last tested version. `LATEST-TESTED` and `LATEST-VERIFIED` are manual-only markers and must change only after compatibility verification. The script rejects unknown or missing required markers and is idempotent. Its GitHub Actions schedule and PR creation have not been verified on this Gitea host; until then, check [upstream Gitea releases](https://github.com/go-gitea/gitea/releases) manually. Publish a new template tag only when template content changes (see [Versioning](#versioning)).
The [tracker workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/gitea-tracker.yml) scans all repository Markdown files. A participating document declares its subject/content pairs in a `DOC-TAGS` JSON comment, then encloses each managed body between `<!-- TRACKER:CONTENT -->` and `<!-- /TRACKER:CONTENT -->` (or `RELEASE` equivalents). Only the first block for a repeated pair in a document is processed. `TRACKER:VERSION-MAP` and `TRACKER:HISTORY` add pending rows; `TRACKER:UPSTREAM` updates upstream versions and status; `TRACKER:BADGE` retains the last tested version. On template publication, the [release workflow](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/.github/workflows/release.yml) updates `RELEASE:HEADER`, `RELEASE:SUMMARY`, and `RELEASE:CURRENT` labels; their links stay fixed at `/releases/latest`, and published tags remain unchanged. `TRACKER:LATEST-TESTED` and `TRACKER:LATEST-VERIFIED` are manual-only and change only after verification. The script rejects undeclared, unknown, or unclosed blocks and missing declarations. Actions execution and write permissions have not been verified on this Gitea host; until then, check [upstream Gitea releases](https://github.com/go-gitea/gitea/releases) and published assets manually. Publish a new template tag only when template content changes (see [Versioning](#versioning)).
## Reporting Issues
+17 -6
View File
@@ -1,10 +1,15 @@
# Gitea Mail Templates
<!-- DOC-TAGS: {"TRACKER":["BADGE","LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]} -->
Polished, drop-in email template themes for self-hosted [Gitea](https://about.gitea.com).
[![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: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 -->
> Latest Release: [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0)
<!-- RELEASE:HEADER -->
> Latest Release: [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
<!-- /RELEASE:HEADER -->
---
@@ -166,10 +171,16 @@ gitea-mail-templates/
## Compatibility
- **Gitea 28.0.0** — use template release v28.0.0; earlier Gitea versions require an older package
- **Latest tested:** Gitea 28.0.0 <!-- TRACKER:LATEST-TESTED -->
- **Latest release:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0) updates all 10 release-notification templates for Gitea's `FormatByteSize` function. Use [v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v1.27.3) for Gitea 1.25.0–1.27.3 (see [COMPATIBILITY.md](COMPATIBILITY.md))
- **Upstream Gitea 28.1.0:** [PENDING] <!-- TRACKER:UPSTREAM -->
<!-- TRACKER:LATEST-TESTED -->
- **Latest tested:** Gitea 28.0.0
<!-- /TRACKER:LATEST-TESTED -->
<!-- RELEASE:SUMMARY -->
- **Latest release:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
<!-- /RELEASE:SUMMARY -->
- For Gitea 1.25.0–1.27.3, use [v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v1.27.3); see [COMPATIBILITY.md](COMPATIBILITY.md) for version-specific limits.
<!-- TRACKER:UPSTREAM -->
- **Upstream Gitea 28.1.0:** [PENDING]
<!-- /TRACKER:UPSTREAM -->
- The current source uses Gitea's official template data paths — see [COMPATIBILITY.md](COMPATIBILITY.md) for release-specific limitations
- Uses only built-in Gitea template functions and official translation keys
- No custom template functions or locale patches required
+14 -4
View File
@@ -1,8 +1,11 @@
# Gitea 邮件模板
<!-- DOC-TAGS: {"TRACKER":["LATEST-TESTED","UPSTREAM"],"RELEASE":["HEADER","SUMMARY"]} -->
为自托管 [Gitea](https://about.gitea.com) 提供精心设计、可直接部署的多风格邮件模板。
> 最新发布版:[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0)
<!-- RELEASE:HEADER -->
> 最新发布版:[v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
<!-- /RELEASE:HEADER -->
---
@@ -91,9 +94,16 @@ go run . dev
## 兼容性
- **Gitea 28.0.0** — 使用模板发布版 v28.0.0;旧版 Gitea 请选用对应的旧版模板
- **最新测试:** Gitea 28.0.0 <!-- TRACKER:LATEST-TESTED -->
- **最新发布版:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v28.0.0) 已将全部 10 个主题的发布通知适配为 `FormatByteSize`;Gitea 1.25.0–1.27.3 请使用 [v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v1.27.3)(详见[兼容性说明](../COMPATIBILITY.md))
- **上游 Gitea 28.1.0:** [PENDING] <!-- TRACKER:UPSTREAM -->
<!-- TRACKER:LATEST-TESTED -->
- **最新测试:** Gitea 28.0.0
<!-- /TRACKER:LATEST-TESTED -->
<!-- RELEASE:SUMMARY -->
- **最新发布版:** [v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/latest)
<!-- /RELEASE:SUMMARY -->
- Gitea 1.25.0–1.27.3 请使用 [v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/releases/tag/v1.27.3);各版本限制参见[兼容性说明](../COMPATIBILITY.md)。
<!-- TRACKER:UPSTREAM -->
- **上游 Gitea 28.1.0:** [PENDING]
<!-- /TRACKER:UPSTREAM -->
- 当前源码使用 Gitea 官方模板的数据路径;各发布版的限制详见 [兼容性说明](../COMPATIBILITY.md)
## 模板类型