chore: refresh docs and adapt workflows for Gitea
This commit is contained in:
1 parent
fec3ace600
commit
f4d96de79e
14 files changed
+986
-505
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"""Gitea API operations for the tracker and release workflows (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
|
||||
|
||||
|
||||
class NoRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Never forward an instance token to a redirect destination.
|
||||
return None
|
||||
|
||||
|
||||
class GiteaAPI:
|
||||
def __init__(self, server, repository, token):
|
||||
parsed = urlsplit(server)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("GITEA_SERVER_URL must be an HTTPS instance URL")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
|
||||
raise ValueError("GITEA_REPOSITORY must be owner/repository")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required")
|
||||
self.base = server.rstrip("/") + "/api/v1/repos/" + repository
|
||||
self.token = token
|
||||
self.opener = build_opener(NoRedirects())
|
||||
|
||||
def request(self, method, path, data=None, content_type="application/json", allow_missing=False):
|
||||
if data is not None and not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode("utf-8")
|
||||
req = Request(self.base + path, data=data, method=method, headers={
|
||||
"Authorization": "token " + self.token,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": content_type,
|
||||
"User-Agent": "GiteaMailTemplates-actions",
|
||||
})
|
||||
try:
|
||||
with self.opener.open(req, timeout=120) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
status = error.code
|
||||
error.close()
|
||||
if allow_missing and status == 404:
|
||||
return None
|
||||
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)
|
||||
|
||||
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:
|
||||
break
|
||||
page += 1
|
||||
body = f"""Gitea **{version}** is recorded as [PENDING]. Verified/tested versions remain unchanged.
|
||||
|
||||
- [ ] 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.
|
||||
"""
|
||||
pull = api.request("POST", "/pulls", {"title": title, "head": branch, "base": "main", "body": body})
|
||||
print(f"[PASS] Created tracking PR: {pull['html_url']}")
|
||||
|
||||
|
||||
def publish_release(api, version, root=Path(".")):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", version):
|
||||
raise ValueError("Expected a stable vX.Y.Z release tag")
|
||||
root = Path(root)
|
||||
lock = json.loads((root / "gitea.lock.json").read_text(encoding="utf-8"))
|
||||
if lock["tag"] != version:
|
||||
raise ValueError("Release tag must match gitea.lock.json")
|
||||
notes = (root / ".github" / "release-notes" / (version + ".md")).read_text(encoding="utf-8")
|
||||
if not notes.strip():
|
||||
raise ValueError("Release notes are empty")
|
||||
assets = [root / "dist" / ("gitea-mail-templates-" + version + ext) for ext in (".zip", ".tar.gz")]
|
||||
for asset in assets:
|
||||
if asset.is_symlink() or not asset.is_file() or not asset.stat().st_size:
|
||||
raise ValueError(f"Missing or invalid release archive: {asset}")
|
||||
existing = api.request("GET", "/releases/tags/" + quote(version, safe=""), allow_missing=True)
|
||||
if existing is not None:
|
||||
raise ValueError("Release already exists; refusing to replace its notes or assets")
|
||||
|
||||
release = api.request("POST", "/releases", {
|
||||
"tag_name": version, "name": version, "body": notes, "draft": True, "prerelease": False,
|
||||
})
|
||||
release_id = int(release["id"])
|
||||
# Gitea accepts raw attachment data with the filename in the query string.
|
||||
# Publish only after both uploads succeed; failures leave a draft for review.
|
||||
for asset in assets:
|
||||
api.request("POST", f"/releases/{release_id}/assets?" + urlencode({"name": asset.name}),
|
||||
asset.read_bytes(), content_type="application/octet-stream")
|
||||
release = api.request("PATCH", f"/releases/{release_id}", {"draft": False})
|
||||
print(f"[PASS] Published {release['html_url']}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("create-pull-request", "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)
|
||||
else:
|
||||
publish_release(api, args.version)
|
||||
except (ValueError, KeyError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Offline Gitea API and disposable-Git workflow regression tests."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("gitea_actions", Path(__file__).with_name("gitea_actions.py"))
|
||||
ACTIONS = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ACTIONS)
|
||||
|
||||
|
||||
class APITests(unittest.TestCase):
|
||||
def test_instance_url_token_and_raw_asset_request(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example/subpath/", "owner/repo", "test-token")
|
||||
response = Mock()
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
response.read.return_value = b'{"id": 1}'
|
||||
api.opener = Mock()
|
||||
api.opener.open.return_value = response
|
||||
self.assertEqual({"id": 1}, api.request("POST", "/releases/1/assets?name=test.zip", b"archive", "application/octet-stream"))
|
||||
request = api.opener.open.call_args.args[0]
|
||||
self.assertEqual("https://git.example/subpath/api/v1/repos/owner/repo/releases/1/assets?name=test.zip", request.full_url)
|
||||
self.assertEqual("token test-token", request.get_header("Authorization"))
|
||||
self.assertEqual(b"archive", request.data)
|
||||
self.assertEqual("application/octet-stream", request.get_header("Content-type"))
|
||||
|
||||
def test_only_explicit_404_is_missing_and_redirects_are_refused(self):
|
||||
api = ACTIONS.GiteaAPI("https://git.example", "owner/repo", "test-token")
|
||||
api.opener = Mock()
|
||||
for status in (401, 403, 500, 302):
|
||||
api.opener.open.side_effect = HTTPError(api.base, status, "error", {}, None)
|
||||
with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"):
|
||||
api.request("GET", "/releases/tags/v28.0.0", allow_missing=True)
|
||||
api.opener.open.side_effect = HTTPError(api.base, 404, "missing", {}, None)
|
||||
self.assertIsNone(api.request("GET", "/releases/tags/v28.0.0", allow_missing=True))
|
||||
self.assertIsNone(ACTIONS.NoRedirects().redirect_request(None, None, 302, "", {}, "https://elsewhere.example"))
|
||||
|
||||
def test_rejects_invalid_configuration(self):
|
||||
for server, repository, token in [("http://git.example", "owner/repo", "x"),
|
||||
("https://user:password@git.example", "owner/repo", "x"),
|
||||
("https://git.example", "../owner/repo", "x"), ("https://git.example", "owner/repo", "")]:
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.GiteaAPI(server, repository, token)
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "gitea.lock.json").write_text(json.dumps({"tag": "v28.0.0"}))
|
||||
notes = self.root / ".github/release-notes/v28.0.0.md"
|
||||
notes.parent.mkdir(parents=True)
|
||||
notes.write_text("Reviewed notes", encoding="utf-8")
|
||||
(self.root / "dist").mkdir()
|
||||
for ext in (".zip", ".tar.gz"):
|
||||
(self.root / "dist" / ("gitea-mail-templates-v28.0.0" + ext)).write_bytes(b"archive")
|
||||
|
||||
def test_existing_release_is_not_modified(self):
|
||||
api = Mock()
|
||||
api.request.return_value = {"id": 5}
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertEqual(["GET"], [call.args[0] for call in api.request.call_args_list])
|
||||
|
||||
def test_publish_only_after_both_uploads_succeed(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, {"id": 6}, {"id": 7}, {"html_url": "https://git.example/release"}]
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
calls = api.request.call_args_list
|
||||
self.assertEqual(["GET", "POST", "POST", "POST", "PATCH"], [c.args[0] for c in calls])
|
||||
self.assertTrue(calls[1].args[2]["draft"])
|
||||
self.assertEqual("Reviewed notes", calls[1].args[2]["body"])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.zip", calls[2].args[1])
|
||||
self.assertEqual("/releases/5/assets?name=gitea-mail-templates-v28.0.0.tar.gz", calls[3].args[1])
|
||||
self.assertEqual({"draft": False}, calls[4].args[2])
|
||||
|
||||
def test_failed_upload_leaves_draft_unpublished(self):
|
||||
api = Mock()
|
||||
api.request.side_effect = [None, {"id": 5}, RuntimeError("upload failed")]
|
||||
with self.assertRaisesRegex(RuntimeError, "upload failed"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
self.assertNotIn("PATCH", [c.args[0] for c in api.request.call_args_list])
|
||||
|
||||
def test_bad_version_or_missing_archive_fails_before_api_call(self):
|
||||
api = Mock()
|
||||
for version in ("v28.0.1", "v28.0.0-rc1", "../../x"):
|
||||
with self.assertRaises(ValueError):
|
||||
ACTIONS.publish_release(api, version, self.root)
|
||||
(self.root / "dist/gitea-mail-templates-v28.0.0.zip").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "archive"):
|
||||
ACTIONS.publish_release(api, "v28.0.0", self.root)
|
||||
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):
|
||||
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")
|
||||
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")
|
||||
|
||||
def test_non_documentation_change_is_not_committed(self):
|
||||
Path("source.txt").write_text("Changed source\n")
|
||||
api = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "only Markdown"):
|
||||
ACTIONS.create_pull_request(api, "28.1.0")
|
||||
api.request.assert_not_called()
|
||||
self.assertEqual("baseline", ACTIONS.git("log", "-1", "--format=%s"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user