chore: simplify workflows and unify tool logging and mail presentation
This commit is contained in:
1 parent
f4d96de79e
commit
c93f2bf8b2
40 files changed
+523
-1515
No files matched your search
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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 -->
|
||||
[](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()
|
||||
@@ -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}")
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
Reference in new issue
Block a user