chore: simplify workflows and unify tool logging and mail presentation
Release / Validate Templates (push) Successful in 2m55s
Release / Package & Release (push) Skipped
Release / Remind to Update Release Documentation (push) Skipped

This commit is contained in:
KenanZhu committed 2026-10-10 14:54:40 +08:00
1 parent f4d96de79e
commit c93f2bf8b2
40 files changed
+523 -1515

No files matched your search

+26 -50
View File
@@ -1,11 +1,10 @@
"""Gitea API operations for the tracker and release workflows (Python stdlib only)."""
"""Gitea API operations for the release workflow (Python stdlib only)."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
from urllib.error import HTTPError
from urllib.parse import quote, urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
@@ -50,56 +49,33 @@ class GiteaAPI:
raise RuntimeError(f"Gitea API {method} {path}: HTTP {status}") from None
def git(*args):
return subprocess.check_output(["git", *args], text=True).strip()
def create_pull_request(api, version):
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
raise ValueError("Expected a stable X.Y.Z upstream version")
branch = "track/gitea-" + version
title = f"Track Gitea {version} compatibility"
changed = git("diff", "--name-only", "HEAD", "--").splitlines()
if any(not name.endswith(".md") for name in changed):
raise ValueError("Tracker may commit only Markdown changes")
if not changed:
print("[PASS] No tracked documentation changes")
return
# Repeated scheduled runs can find a branch from an earlier pending PR.
# Reuse identical content, but never force-push over a changed branch.
remote = git("ls-remote", "--heads", "origin", "refs/heads/" + branch)
if remote:
git("fetch", "origin", "refs/heads/" + branch)
if git("diff", "--name-only", "FETCH_HEAD", "--"):
raise ValueError(f"Existing {branch} differs; review it before updating the tracking PR")
else:
git("switch", "-c", branch)
git("add", "--", *changed)
git("-c", "user.name=release-bot", "-c", "user.email=release-bot@users.noreply.local",
"commit", "-m", f"docs: track Gitea {version} pending verification")
git("push", "origin", "HEAD:refs/heads/" + branch)
def create_issue(api, version):
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
raise ValueError("Expected a stable vX.Y.Z release tag")
marker = f"<!-- gitea-mail-templates:template-release:{version} -->"
title = f"Update documentation for template release {version}"
page = 1
while True:
pulls = api.request("GET", f"/pulls?state=open&base_branch=main&limit=50&page={page}")
for pull in pulls:
if (pull["head"]["ref"] == branch and pull["base"]["ref"] == "main"
and pull["head"]["repo"]["full_name"] == pull["base"]["repo"]["full_name"]):
print(f"[PASS] Tracking PR already exists: {pull['html_url']}")
return
if not pulls:
issues = api.request("GET", f"/issues?state=all&type=issues&limit=50&page={page}")
for issue in issues:
if issue.get("pull_request") is None and (marker in (issue.get("body") or "") or issue.get("title") == title):
print(f"[PASS] Reminder issue already exists: {issue['html_url']}")
return issue
if not issues:
break
page += 1
body = f"""Gitea **{version}** is recorded as [PENDING]. Verified/tested versions remain unchanged.
body = f"""{marker}
Template release **{version}** has been published. Documentation is maintained manually.
- [ ] Review upstream mail templates, mailer data, functions and translation keys.
- [ ] Run `go test ./...` and `go run . preview all` from `tools/`.
- [ ] Record results in `COMPATIBILITY.md`; update verified status only after testing.
- [ ] Release template changes separately with reviewed notes and assets.
- [ ] Update the release version in the English and Chinese READMEs.
- [ ] Review compatibility records and mark versions verified only according to test results.
- [ ] Check release links, release notes and remaining version references.
This reminder does not change repository files, branches or existing release assets.
"""
pull = api.request("POST", "/pulls", {"title": title, "head": branch, "base": "main", "body": body})
print(f"[PASS] Created tracking PR: {pull['html_url']}")
issue = api.request("POST", "/issues", {"title": title, "body": body})
print(f"[PASS] Created reminder issue: {issue['html_url']}")
return issue
def publish_release(api, version, root=Path(".")):
@@ -135,16 +111,16 @@ def publish_release(api, version, root=Path(".")):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("create-pull-request", "publish-release"))
parser.add_argument("operation", choices=("create-issue", "publish-release"))
parser.add_argument("--version", required=True)
args = parser.parse_args()
try:
api = GiteaAPI(os.environ.get("GITEA_SERVER_URL", ""), os.environ.get("GITEA_REPOSITORY", ""), os.environ.get("GITEA_TOKEN", ""))
if args.operation == "create-pull-request":
create_pull_request(api, args.version)
if args.operation == "create-issue":
create_issue(api, args.version)
else:
publish_release(api, args.version)
except (ValueError, KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
except (ValueError, KeyError, OSError, RuntimeError) as error:
parser.exit(1, f"[FAIL] {error}\n")
+8 -1
View File
@@ -87,8 +87,15 @@ def collect(root, version):
if sorted(payload) != names or any(set(payload[name]) != set(manifest["registry"]) for name in names):
raise ValueError(f"Incomplete preview language bundle: {lang}")
files[f"preview/rendered/{lang}.js"] = data
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
files[name] = read_file(root / name)
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
if (root / name).exists():
files[name] = read_file(root / name)
for directory in [root / "docs", root / ".github" / "release-notes"]:
for path in sorted(directory.rglob("*.md")):
name = path.relative_to(root).as_posix()
files[name] = read_file(checked_path(root, name))
for path in sorted((root / "docs" / "images").iterdir()):
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
files["docs/images/" + path.name] = read_file(path)
+33 -51
View File
@@ -1,10 +1,8 @@
"""Offline Gitea API and disposable-Git workflow regression tests."""
"""Offline Gitea API and issue-reminder regression tests."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import Mock
@@ -101,59 +99,43 @@ class ReleaseTests(unittest.TestCase):
api.request.assert_not_called()
class PullRequestTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.previous = Path.cwd()
os.chdir(self.root)
self.addCleanup(os.chdir, self.previous)
subprocess.run(["git", "init", "--bare", "remote.git"], check=True, capture_output=True)
subprocess.run(["git", "init", "-b", "main", "work"], check=True, capture_output=True)
os.chdir(self.root / "work")
ACTIONS.git("config", "user.name", "test")
ACTIONS.git("config", "user.email", "test@example.invalid")
ACTIONS.git("remote", "add", "origin", str(self.root / "remote.git"))
Path("README.md").write_text("Baseline\n")
Path("source.txt").write_text("Source\n")
ACTIONS.git("add", ".")
ACTIONS.git("commit", "-m", "baseline")
ACTIONS.git("push", "origin", "main")
Path("README.md").write_text("Pending 28.1.0\n")
def test_create_then_reuse_branch_and_paginated_pr(self):
class IssueTests(unittest.TestCase):
def test_creates_release_documentation_issue(self):
api = Mock()
api.request.side_effect = [[], {"html_url": "https://git.example/pulls/1"}]
ACTIONS.create_pull_request(api, "28.1.0")
self.assertEqual("track/gitea-28.1.0", ACTIONS.git("branch", "--show-current"))
payload = api.request.call_args.args[2]
self.assertEqual("main", payload["base"])
self.assertEqual("track/gitea-28.1.0", payload["head"])
self.assertIn("[PENDING]", payload["body"])
original_head = ACTIONS.git("rev-parse", "HEAD")
ACTIONS.git("switch", "main")
Path("README.md").write_text("Pending 28.1.0\n")
api.reset_mock()
api.request.side_effect = [[{"head": {"ref": "unrelated"}}], [{
"head": {"ref": "track/gitea-28.1.0", "repo": {"full_name": "owner/repo"}},
"base": {"ref": "main", "repo": {"full_name": "owner/repo"}},
"html_url": "https://git.example/pulls/1",
}]]
ACTIONS.create_pull_request(api, "28.1.0")
api.request.side_effect = [[], {"html_url": "https://git.example/issues/1"}]
ACTIONS.create_issue(api, "v28.1.0")
calls = api.request.call_args_list
self.assertEqual(["GET", "POST"], [c.args[0] for c in calls])
self.assertEqual("/issues", calls[-1].args[1])
payload = calls[-1].args[2]
self.assertIn("<!-- gitea-mail-templates:template-release:v28.1.0 -->", payload["body"])
self.assertIn("Documentation is maintained manually", payload["body"])
def test_reuses_closed_issue_on_later_page_and_ignores_pull_requests(self):
api = Mock()
marker = "<!-- gitea-mail-templates:template-release:v28.1.0 -->"
api.request.side_effect = [[{"body": marker, "pull_request": {"url": "pr"}}],
[{"body": marker, "state": "closed", "title": "Renamed", "html_url": "https://git.example/issues/1"}]]
ACTIONS.create_issue(api, "v28.1.0")
self.assertEqual(["GET", "GET"], [c.args[0] for c in api.request.call_args_list])
self.assertEqual(original_head, ACTIONS.git("rev-parse", "FETCH_HEAD"))
Path("README.md").write_text("Different pending content\n")
with self.assertRaisesRegex(ValueError, "Existing.*differs"):
ACTIONS.create_pull_request(api, "28.1.0")
self.assertIn("state=all", api.request.call_args_list[0].args[1])
self.assertIn("page=2", api.request.call_args_list[1].args[1])
def test_non_documentation_change_is_not_committed(self):
Path("source.txt").write_text("Changed source\n")
def test_other_release_versions_do_not_suppress_reminder(self):
api = Mock()
with self.assertRaisesRegex(ValueError, "only Markdown"):
ACTIONS.create_pull_request(api, "28.1.0")
api.request.side_effect = [[{"body": "<!-- gitea-mail-templates:template-release:v28.0.0 -->"}], [],
{"html_url": "https://git.example/issues/2"}]
ACTIONS.create_issue(api, "v28.1.0")
payload = api.request.call_args.args[2]
self.assertIn("template-release:v28.1.0", payload["body"])
self.assertIn("READMEs", payload["body"])
def test_invalid_versions_fail_before_api_call(self):
api = Mock()
for version in ("28.1.0", "v28.1.0-rc1", "../../x"):
with self.assertRaises(ValueError):
ACTIONS.create_issue(api, version)
api.request.assert_not_called()
self.assertEqual("baseline", ACTIONS.git("log", "-1", "--format=%s"))
if __name__ == "__main__":
+25 -1
View File
@@ -33,7 +33,7 @@ class PackagingTests(unittest.TestCase):
manifest = {"upstream": self.tag, "upstream_commit": self.commit, "themes": ["demo"], "languages": ["en-US"], "registry": {"activate": {}}}
self.write("preview/rendered.js", ("window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";").encode())
self.write("preview/rendered/en-US.js", ('window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps({"demo": {"activate": "<html></html>"}}) + ";").encode())
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html", "docs/images/README.md"]:
for name in ["LICENSE", "AGENTS.md", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html", "docs/images/README.md"]:
self.write(name, b"test")
def write(self, name, data):
@@ -72,6 +72,30 @@ class PackagingTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
PACKER.collect(self.root, self.tag)
def test_includes_nested_documentation_and_release_note_sources(self):
documents = {
"docs/INDEX.zh-CN.md": "中文索引".encode(),
"docs/images/README.zh-CN.md": "截图指南".encode(),
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode(),
".github/release-notes/v28.0.0.md": b"English release notes",
}
for name, data in documents.items():
self.write(name, data)
self.write("docs/private.tmp", b"not documentation")
files = PACKER.collect(self.root, self.tag)
for name, data in documents.items():
self.assertEqual(files[name], data)
self.assertIn("AGENTS.md", files)
self.assertNotIn("docs/private.tmp", files)
def test_packages_without_removed_optional_root_documents(self):
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]:
(self.root / name).unlink()
files = PACKER.collect(self.root, self.tag)
self.assertNotIn("AGENTS.md", files)
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
self.assertIn("GITEA-LICENSE", files)
if __name__ == "__main__":
unittest.main()
-285
View File
@@ -1,285 +0,0 @@
"""Offline tests for the compatibility tracker."""
import importlib.util
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
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"]} -->
<!-- TRACKER:LATEST-VERIFIED -->
> **Latest verified:** Release v28.0.0 passes tests.
<!-- /TRACKER:LATEST-VERIFIED -->
<!-- TRACKER:VERSION-MAP -->
| Gitea version | Recommended template release | Status | Notes |
|---|---|---|---|
| 28.1.0 | — | [PENDING] | Compatibility verification pending |
| 28.0.0 | **v28.0.0** | [PASS] | Tested |
<!-- /TRACKER:VERSION-MAP -->
<!-- TRACKER:HISTORY -->
| Gitea | Release Date | Mail Template Changes | Impact |
|---|---|---|---|
| **28.1.0** | 2026-10-06 | [PENDING] Review of mail-template changes | 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):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="tracker-test-", dir=TRACKER.ROOT)
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.paths = ("COMPATIBILITY.md", "README.md", "AGENTS.md", "docs/README.zh-CN.md")
self.originals = {}
for name in self.paths:
destination = self.root / name
destination.parent.mkdir(parents=True, exist_ok=True)
content = FIXTURES[name]
destination.write_text(content, encoding="utf-8")
self.originals[name] = content
def read(self, name):
return (self.root / name).read_text(encoding="utf-8")
def assert_unchanged(self):
for name, content in self.originals.items():
self.assertEqual(content, self.read(name), name)
def test_new_release_adds_only_pending_rows(self):
changed = TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertEqual({self.root / name for name in self.paths}, set(changed))
compatibility = self.read("COMPATIBILITY.md")
self.assertIn("| 28.2.0 | — | [PENDING] | Compatibility verification pending |", compatibility)
self.assertIn("| **28.2.0** | 2026-11-01 | [PENDING]", compatibility)
self.assertIn("| 28.0.0 | **v28.0.0** | [PASS] | Tested |", compatibility)
self.assertIn("Latest verified:** Release v28.0.0", compatibility)
self.assertIn("Gitea-28.2.0%20%5BPENDING%5D%20%7C%2028.0.0%20tested-yellow", self.read("README.md"))
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("README.md"))
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("docs/README.zh-CN.md"))
self.assertIn("Gitea release: 28.2.0 [PENDING]", self.read("AGENTS.md"))
self.assertIn("Latest tested:** Gitea 28.0.0", self.read("README.md"))
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)
matrix = next(block for block in blocks if (block.subject, block.content) == ("TRACKER", "VERSION-MAP"))
history = next(block for block in blocks if (block.subject, block.content) == ("TRACKER", "HISTORY"))
self.assertEqual(
TRACKER.table(TRACKER.body(documents[matrix.path], matrix), "VERSION-MAP"),
TRACKER.table(TRACKER.body(documents[history.path], history), "HISTORY"),
)
def test_existing_version_is_idempotent(self):
self.assertEqual([], TRACKER.apply_release(self.root, "28.1.0", "2026-10-06"))
self.assert_unchanged()
def test_partial_update_is_repaired(self):
compatibility = self.root / "COMPATIBILITY.md"
partial = self.read("COMPATIBILITY.md").replace("| 28.1.0 | — | [PENDING] | Compatibility verification pending |\n", "")
compatibility.write_text(partial, encoding="utf-8")
self.assertEqual([compatibility], TRACKER.apply_release(self.root, "28.1.0", "2026-10-06"))
self.assertIn("| 28.1.0 | — | [PENDING] | Compatibility verification pending |", self.read("COMPATIBILITY.md"))
def test_older_version_does_not_rewrite_history(self):
self.assertEqual([], TRACKER.apply_release(self.root, "28.0.0", "2026-09-29"))
self.assert_unchanged()
def test_dry_run_does_not_write(self):
self.assertEqual(4, len(TRACKER.apply_release(self.root, "28.2.0", "2026-11-01", dry_run=True)))
self.assert_unchanged()
def test_missing_marker_fails_without_writing(self):
compatibility = self.root / "COMPATIBILITY.md"
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('<!-- 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 / "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.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"
chinese.write_text(self.read("docs/README.zh-CN.md").replace("**最新测试:** Gitea 28.0.0", "**最新测试:** Gitea 28.1.0"), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "disagree"):
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
def test_after_verification_new_version_becomes_pending(self):
english = self.root / "README.md"
chinese = self.root / "docs/README.zh-CN.md"
agents = self.root / "AGENTS.md"
english.write_text(self.read("README.md")
.replace("**Latest tested:** Gitea 28.0.0", "**Latest tested:** Gitea 28.1.0")
.replace("Gitea-28.1.0%20%5BPENDING%5D%20%7C%2028.0.0%20tested-yellow", "Gitea-28.1.0%20%7C%2028.1.0%20tested-blue")
.replace("**Upstream Gitea 28.1.0:** [PENDING]", "**Upstream Gitea 28.1.0:** [PASS]"), encoding="utf-8")
chinese.write_text(self.read("docs/README.zh-CN.md")
.replace("**最新测试:** Gitea 28.0.0", "**最新测试:** Gitea 28.1.0")
.replace("**上游 Gitea 28.1.0:** [PENDING]", "**上游 Gitea 28.1.0:** [PASS]"), encoding="utf-8")
agents.write_text(self.read("AGENTS.md").replace("28.1.0 [PENDING]", "28.1.0 [PASS]"), encoding="utf-8")
TRACKER.apply_release(self.root, "28.2.0", "2026-11-01")
self.assertIn("Gitea-28.2.0%20%5BPENDING%5D%20%7C%2028.1.0%20tested-yellow", self.read("README.md"))
self.assertIn("Gitea 28.2.0:** [PENDING]", self.read("README.md"))
self.assertIn("Latest tested:** Gitea 28.1.0", self.read("README.md"))
self.assertIn("最新测试:** Gitea 28.1.0", self.read("docs/README.zh-CN.md"))
def test_invalid_version_rejected(self):
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:
self.assertEqual(("28.2.0", "2026-11-01"), TRACKER.fetch_release("28.2.0"))
self.assertEqual(f"{TRACKER.RELEASES_API}/tags/v28.2.0", mocked.call_args.args[0].full_url)
def test_prerelease_is_rejected(self):
payload = {"tag_name": "v28.2.0", "published_at": "2026-11-01T08:15:00Z", "prerelease": True}
with patch.object(TRACKER, "urlopen", return_value=io.BytesIO(json.dumps(payload).encode("utf-8"))):
with self.assertRaises(ValueError):
TRACKER.fetch_release(None)
if __name__ == "__main__":
unittest.main()
-311
View File
@@ -1,311 +0,0 @@
#!/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}")
-47
View File
@@ -1,47 +0,0 @@
name: Gitea Version Tracker
on:
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
version:
description: 'Stable Gitea version (e.g. 28.2.0); empty means latest'
required: false
jobs:
track:
name: Track Gitea Release
runs-on: linux-amd64-docker-small
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITEA_TOKEN }}
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test tracker
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
- name: Record pending Gitea release
id: version
run: python -B .github/scripts/track_gitea_release.py
env:
GITEA_VERSION: ${{ github.event.inputs.version }}
# Optional GitHub token for upstream API rate limits; never use the Gitea job token.
GITHUB_TOKEN: ${{ secrets.UPSTREAM_GITHUB_TOKEN }}
- name: Create Pull Request
if: steps.version.outputs.changed == 'true'
run: python -B .github/scripts/gitea_actions.py create-pull-request --version "$UPSTREAM_VERSION"
env:
UPSTREAM_VERSION: ${{ steps.version.outputs.version }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+13 -28
View File
@@ -20,7 +20,7 @@ jobs:
with:
python-version: '3.11'
- name: Test documentation tracker
- name: Test workflow scripts
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
- name: Verify pinned official snapshot
@@ -64,11 +64,6 @@ jobs:
- 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
@@ -86,36 +81,26 @@ jobs:
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
sync-release-docs:
name: Update Latest Release Documentation
notify-release-docs:
name: Remind to Update Release Documentation
if: startsWith(github.ref, 'refs/tags/v')
needs: package
concurrency:
group: release-documentation-${{ github.ref }}
cancel-in-progress: false
runs-on: linux-amd64-docker-small
permissions:
contents: write
contents: read
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITEA_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
- name: Create documentation reminder Issue
run: python -B .github/scripts/gitea_actions.py create-issue --version "$TEMPLATE_RELEASE"
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}