chore: split validation and release workflows and refactor packaging

This commit is contained in:
KenanZhu committed 2026-10-11 09:53:25 +08:00
1 parent 5b06a795e4
commit 5f5ad058a8
9 files changed
+379 -487

No files matched your search

-16
View File
@@ -1,16 +0,0 @@
# v1.27.2
> **Correction (2026-09-23):** The push-notification fix in this release is incomplete. Bloom, Ember and Heritage still use the old commit ID path and can fail to render push notifications on Gitea 1.27.1+. Use v1.27.3 for the complete fix; see the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
## Changes
- Add the `email-text` class to Aurora release paragraphs for consistent typography.
- Update push commit paths in seven themes for Gitea 1.27.1 compatibility (`.ID` → `.UserCommit.GitCommit.ID`). The three remaining themes are covered by the correction above.
- Replace `rgba()` colors with `#RRGGBBAA` notation across all ten themes.
## Documentation
- Record compatibility for Gitea 1.27.0, 1.27.1 and 1.27.2.
- Maintain English and Simplified Chinese README and contributor guides.
[Full changelog: v1.0.1…v1.27.2](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.0.1...v1.27.2)
-20
View File
@@ -1,20 +0,0 @@
# v1.27.3
This release completes the push-notification fix for Gitea 1.27.1–1.27.3. See the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix) for recommended release combinations.
## Fixes
- Fix pull request push-notification links and abbreviated hashes in Bloom, Ember and Heritage by reading `.UserCommit.GitCommit.ID`.
- Add a push-notification regression test covering all ten themes and run Go tests before packaging.
## Packaging
- Include English and Simplified Chinese README and contributor guides in release archives.
- Use reviewed release notes in the publishing workflow.
## Documentation
- Record compatibility with Gitea 1.27.3, whose mail templates, mailer and locale files are unchanged from 1.27.2.
- Distinguish the affected v1.27.2 archive from the complete fix in this release.
[Full changelog: v1.27.2…v1.27.3](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.2...v1.27.3)
-18
View File
@@ -1,18 +0,0 @@
# v28.0.0
This release targets Gitea 28.0.0 and includes ten themes with eleven mail types each. For earlier Gitea versions, use the packages listed in the [compatibility matrix](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/src/branch/main/COMPATIBILITY.md#compatibility-matrix).
## Fixes
- Replace the removed `FileSize` function with `FormatByteSize` in release-attachment emails across all ten themes.
- Update the preview's file-size function for Gitea 28.0.0 and include release attachments in the example data.
- Add an all-theme regression test for release attachments and the removed function.
## Documentation
- Update both READMEs and the compatibility matrix for Gitea 28.0.0.
- Document the Gitea 28.0.0 requirement and direct users of earlier versions to the compatibility matrix.
The snapshot-based source architecture now on `main` is separate, unreleased work. It is not included in the existing v28.0.0 archives.
[Full changelog: v1.27.3…v28.0.0](https://gitea.kenanzhu.com/KenanZhu/GiteaMailTemplates/compare/v1.27.3...v28.0.0)
-128
View File
@@ -1,128 +0,0 @@
"""Gitea API operations for the release workflow (Python stdlib only)."""
import argparse
import json
import os
from pathlib import Path
import re
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 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:
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"""{marker}
Template release **{version}** has been published. Documentation is maintained manually.
- [ ] 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.
"""
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(".")):
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-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-issue":
create_issue(api, args.version)
else:
publish_release(api, args.version)
except (ValueError, KeyError, OSError, RuntimeError) as error:
parser.exit(1, f"[FAIL] {error}\n")
if __name__ == "__main__":
main()
+153 -45
View File
@@ -4,10 +4,28 @@ import argparse
import hashlib
import io
import json
from pathlib import Path, PurePosixPath
import re
import tarfile
import zipfile
from pathlib import Path, PurePosixPath
VERSION_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+")
THEME_NAME_PATTERN = re.compile(r"[a-z][a-z0-9-]*")
BASE_TEMPLATES = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
REQUIRED_FILES = (
"LICENSE",
"README.md",
"CONTRIBUTING.md",
"COMPATIBILITY.md",
"docs/README.zh-CN.md",
"docs/CONTRIBUTING.zh-CN.md",
"preview/index.html",
)
OPTIONAL_ROOT_DOCUMENTS = ("AGENTS.md", "THIRD_PARTY_NOTICES.md")
DOCUMENTATION_DIRECTORIES = ("docs", ".github/release-notes")
PREVIEW_MANIFEST_PREFIX = b"window.__MAIL_PREVIEW__ = "
ARCHIVE_EXTENSIONS = (".zip", ".tar.gz")
def read_file(path):
@@ -31,41 +49,75 @@ def digest(data):
return hashlib.sha256(data).hexdigest()
def collect(root, version):
root = Path(root)
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
def read_json(path):
return json.loads(read_file(path))
def load_lock(root, version):
"""Validate the requested release identity before collecting any output."""
if not VERSION_PATTERN.fullmatch(version):
raise ValueError("Expected a stable vX.Y.Z package version")
lock = json.loads(read_file(root / "gitea.lock.json"))
lock = read_json(root / "gitea.lock.json")
if version != lock["tag"]:
raise ValueError("Package version must match the locked Gitea tag")
files = {}
names = sorted(p.name for p in (root / "themes").iterdir() if p.is_dir() and not p.is_symlink())
return lock
def discover_theme_names(root):
names = sorted(
path.name
for path in (root / "themes").iterdir()
if path.is_dir() and not path.is_symlink()
)
if not names:
raise ValueError("No source theme manifests")
for name in names:
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
return names
def expected_template_paths(root, lock, mode):
"""Shared themes override base files; framed themes also supply all bodies."""
paths = set(BASE_TEMPLATES)
if mode == "framed":
paths.update(
path.removeprefix("templates/")
for path in lock["files"]
if path.startswith("templates/mail/") and path.endswith(".tmpl")
)
paths.update(
path.relative_to(root / "framework").as_posix()
for path in (root / "framework" / "mail").rglob("*.tmpl")
)
return paths
def collect_theme(root, lock, name):
"""Accept only a complete build whose identity and checksums still match."""
if not THEME_NAME_PATTERN.fullmatch(name):
raise ValueError(f"Invalid theme name: {name}")
source = root / "themes" / name
theme = json.loads(read_file(source / "theme.json"))
theme = read_json(source / "theme.json")
if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
raise ValueError(f"Invalid theme metadata: {name}")
built = root / "build" / "themes" / name
meta = json.loads(read_file(built / "build.json"))
if (meta["theme"], meta["mode"], meta["gitea_tag"], meta["gitea_commit"]) != (name, theme["mode"], version, lock["commit"]):
metadata = read_json(built / "build.json")
actual_identity = (
metadata["theme"], metadata["mode"],
metadata["gitea_tag"], metadata["gitea_commit"],
)
expected_identity = (name, theme["mode"], lock["tag"], lock["commit"])
if actual_identity != expected_identity:
raise ValueError(f"Stale build identity: {name}")
if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.get("files"):
has_sources = {"theme.json", "theme.css"}.issubset(metadata.get("sources") or {})
if not has_sources or not metadata.get("files"):
raise ValueError(f"Missing source/file provenance: {name}")
expected_files = {"mail/base/head.tmpl", "mail/base/footer.tmpl"}
if theme["mode"] == "framed":
expected_files.update(path.removeprefix("templates/") for path in lock["files"] if path.startswith("templates/mail/") and path.endswith(".tmpl"))
expected_files.update(path.relative_to(root / "framework").as_posix() for path in (root / "framework" / "mail").rglob("*.tmpl"))
if set(meta["files"]) != expected_files:
if set(metadata["files"]) != expected_template_paths(root, lock, theme["mode"]):
raise ValueError(f"Incomplete generated install package: {name}")
for path, expected in meta["sources"].items():
for path, expected in metadata["sources"].items():
base = root if path.startswith(("framework/", "tools/")) else source
if digest(read_file(checked_path(base, path))) != expected:
raise ValueError(f"Source changed since build: {name}/{path}")
for path, expected in meta["files"].items():
files = {}
for path, expected in metadata["files"].items():
if not path.startswith("mail/") or not path.endswith(".tmpl"):
raise ValueError(f"Unexpected generated file: {path}")
data = read_file(checked_path(built, path))
@@ -73,59 +125,111 @@ def collect(root, version):
raise ValueError(f"Generated checksum mismatch: {name}/{path}")
files[f"themes/{name}/{path}"] = data
files[f"themes/{name}/build.json"] = read_file(built / "build.json")
return files
def parse_script_payload(data, prefix):
"""Read generated JSON without executing the surrounding JavaScript."""
return json.loads(data.split(prefix, 1)[1].strip().removesuffix(b";"))
def collect_preview(root, lock, theme_names):
manifest_data = read_file(root / "preview" / "rendered.js")
prefix = b"window.__MAIL_PREVIEW__ = "
manifest = json.loads(manifest_data.split(prefix, 1)[1].strip().removesuffix(b";"))
languages = sorted(path.removeprefix("options/locale/locale_").removesuffix(".json") for path in lock["files"] if path.startswith("options/locale/locale_") and path.endswith(".json"))
if manifest["upstream"] != version or manifest.get("upstream_commit") != lock["commit"] or sorted(manifest["themes"]) != names or sorted(manifest["languages"]) != languages:
raise ValueError("Preview does not match lock/all source themes and languages; run preview all")
files["preview/rendered.js"] = manifest_data
for lang in languages:
data = read_file(root / "preview" / "rendered" / (lang + ".js"))
marker = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(lang) + '] = ').encode()
payload = json.loads(data.split(marker, 1)[1].strip().removesuffix(b";"))
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", "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"]:
manifest = parse_script_payload(manifest_data, PREVIEW_MANIFEST_PREFIX)
languages = sorted(
path.removeprefix("options/locale/locale_").removesuffix(".json")
for path in lock["files"]
if path.startswith("options/locale/locale_") and path.endswith(".json")
)
matches_build = (
manifest["upstream"] == lock["tag"]
and manifest.get("upstream_commit") == lock["commit"]
and sorted(manifest["themes"]) == theme_names
and sorted(manifest["languages"]) == languages
)
if not matches_build:
raise ValueError(
"Preview does not match lock/all source themes and languages; run preview all"
)
files = {"preview/rendered.js": manifest_data}
expected_templates = set(manifest["registry"])
for language in languages:
data = read_file(root / "preview" / "rendered" / (language + ".js"))
prefix = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(language) + '] = ').encode()
payload = parse_script_payload(data, prefix)
if sorted(payload) != theme_names or any(
set(payload[name]) != expected_templates for name in theme_names
):
raise ValueError(f"Incomplete preview language bundle: {language}")
files[f"preview/rendered/{language}.js"] = data
return files
def collect_documentation(root):
files = {name: read_file(root / name) for name in REQUIRED_FILES}
for name in OPTIONAL_ROOT_DOCUMENTS:
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")):
for directory in DOCUMENTATION_DIRECTORIES:
for path in sorted((root / 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)
return files
def collect_provenance(root, lock):
license_data = read_file(root / "build" / "upstream" / "LICENSE")
if digest(license_data) != lock["files"]["LICENSE"]:
raise ValueError("Upstream license checksum mismatch")
files["GITEA-LICENSE"] = license_data
files["upstream-lock.json"] = read_file(root / "gitea.lock.json")
return {
"GITEA-LICENSE": license_data,
"upstream-lock.json": read_file(root / "gitea.lock.json"),
}
def collect(root, version):
"""Collect verified release content without writing any archives."""
root = Path(root)
lock = load_lock(root, version)
theme_names = discover_theme_names(root)
files = {}
for name in theme_names:
files.update(collect_theme(root, lock, name))
files.update(collect_preview(root, lock, theme_names))
files.update(collect_documentation(root))
files.update(collect_provenance(root, lock))
return files
def package(root, version, output):
"""Write both archive formats from the same verified file collection."""
files = collect(root, version)
archive = f"gitea-mail-templates-{version}"
output = Path(output)
targets = [output / (archive + ext) for ext in (".zip", ".tar.gz")]
targets = [output / (archive + ext) for ext in ARCHIVE_EXTENSIONS]
if any(path.exists() for path in targets):
raise ValueError("Refusing to overwrite existing release archives; use a new output directory")
raise ValueError(
"Refusing to overwrite existing release archives; use a new output directory"
)
output.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped, tarfile.open(targets[1], "w:gz") as tar:
with (
zipfile.ZipFile(targets[0], "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zipped,
tarfile.open(targets[1], "w:gz") as tar,
):
for name, data in sorted(files.items()):
path = archive + "/" + name
zipped.writestr(path, data)
info = tarfile.TarInfo(path)
info.size, info.mode = len(data), 0o644
info.size = len(data)
info.mode = 0o644
tar.addfile(info, io.BytesIO(data))
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
if __name__ == "__main__":
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
@@ -135,3 +239,7 @@ if __name__ == "__main__":
package(args.root, args.version, args.output)
except (ValueError, KeyError, IndexError, OSError) as error:
parser.exit(1, f"[FAIL] {error}\n")
if __name__ == "__main__":
main()
-142
View File
@@ -1,142 +0,0 @@
"""Offline Gitea API and issue-reminder regression tests."""
import importlib.util
import json
from pathlib import Path
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 IssueTests(unittest.TestCase):
def test_creates_release_documentation_issue(self):
api = Mock()
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.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_other_release_versions_do_not_suppress_reminder(self):
api = Mock()
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()
if __name__ == "__main__":
unittest.main()
+149 -50
View File
@@ -1,97 +1,196 @@
"""Offline artifact/provenance tests independent of generated repository output."""
"""Offline packaging tests using a minimal, independently generated repository."""
import importlib.util
import json
from pathlib import Path
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).with_name("package_release.py"))
SCRIPT_PATH = Path(__file__).with_name("package_release.py")
SPEC = importlib.util.spec_from_file_location("package_release", SCRIPT_PATH)
PACKER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKER)
class PackagingTests(unittest.TestCase):
VERSION = "v28.0.0"
COMMIT = "a" * 40
THEME = "demo"
LANGUAGE = "en-US"
ROOT_DOCUMENTS = (
"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",
)
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.tag = "v28.0.0"
self.commit = "a" * 40
theme = json.dumps({"name": "demo", "mode": "shared"}).encode()
css = b"a { color:blue; }"
self.write("themes/demo/theme.json", theme)
self.write("themes/demo/theme.css", css)
files = {"mail/base/head.tmpl": b"<title>test</title>", "mail/base/footer.tmpl": b"<p>footer</p>"}
meta = {"theme": "demo", "mode": "shared", "gitea_tag": self.tag, "gitea_commit": self.commit, "sources": {"theme.json": PACKER.digest(theme), "theme.css": PACKER.digest(css)}, "files": {p: PACKER.digest(data) for p, data in files.items()}}
for path, data in files.items():
self.write("build/themes/demo/" + path, data)
self.write_json("build/themes/demo/build.json", meta)
self.write_json("gitea.lock.json", {"tag": self.tag, "commit": self.commit, "files": {"LICENSE": PACKER.digest(b"MIT"), "options/locale/locale_en-US.json": PACKER.digest(b"{}")}})
self.write("build/upstream/LICENSE", b"MIT")
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", "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"]:
temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(temporary_directory.cleanup)
self.root = Path(temporary_directory.name)
self.output = self.root / "artifacts"
self.create_theme()
self.create_snapshot()
self.create_preview()
for name in self.ROOT_DOCUMENTS:
self.write(name, b"test")
# Fixture helpers keep test bodies focused on the behavior being checked.
def write(self, name, data):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def write_json(self, name, data):
self.write(name, json.dumps(data).encode())
self.write(name, json.dumps(data).encode("utf-8"))
def test_packages_declared_outputs_only_and_never_overwrites(self):
def create_theme(self):
theme_data = json.dumps({"name": self.THEME, "mode": "shared"}).encode("utf-8")
css_data = b"a { color:blue; }"
self.write("themes/demo/theme.json", theme_data)
self.write("themes/demo/theme.css", css_data)
generated_files = {
"mail/base/head.tmpl": b"<title>test</title>",
"mail/base/footer.tmpl": b"<p>footer</p>",
}
metadata = {
"theme": self.THEME,
"mode": "shared",
"gitea_tag": self.VERSION,
"gitea_commit": self.COMMIT,
"sources": {
"theme.json": PACKER.digest(theme_data),
"theme.css": PACKER.digest(css_data),
},
"files": {
path: PACKER.digest(data)
for path, data in generated_files.items()
},
}
for path, data in generated_files.items():
self.write("build/themes/demo/" + path, data)
self.write_json("build/themes/demo/build.json", metadata)
def create_snapshot(self):
self.write_json("gitea.lock.json", {
"tag": self.VERSION,
"commit": self.COMMIT,
"files": {
"LICENSE": PACKER.digest(b"MIT"),
"options/locale/locale_en-US.json": PACKER.digest(b"{}"),
},
})
self.write("build/upstream/LICENSE", b"MIT")
def create_preview(self):
manifest = {
"upstream": self.VERSION,
"upstream_commit": self.COMMIT,
"themes": [self.THEME],
"languages": [self.LANGUAGE],
"registry": {"activate": {}},
}
bundle = {self.THEME: {"activate": "<html></html>"}}
self.write("preview/rendered.js", (
"window.__MAIL_PREVIEW__ = " + json.dumps(manifest) + ";"
).encode("utf-8"))
self.write("preview/rendered/en-US.js", (
'window.__MAIL_PREVIEW_DATA__["en-US"] = ' + json.dumps(bundle) + ";"
).encode("utf-8"))
# Archives: both formats contain the verified bytes, never stale build files.
def test_archives_contain_only_verified_outputs(self):
self.write("build/themes/stale/mail/stale.tmpl", b"stale")
out = self.root / "artifacts"
PACKER.package(self.root, self.tag, out)
with zipfile.ZipFile(next(out.glob("*.zip"))) as archive:
names = archive.namelist()
self.assertTrue(any(name.endswith("themes/demo/mail/base/head.tmpl") for name in names))
self.assertFalse(any("stale" in name or "/build/" in name for name in names))
with self.assertRaises(ValueError):
PACKER.package(self.root, self.tag, out)
expected_files = PACKER.collect(self.root, self.VERSION)
prefix = f"gitea-mail-templates-{self.VERSION}/"
expected_members = {
prefix + name: data for name, data in expected_files.items()
}
def test_refuses_stale_source_tampered_output_and_wrong_version(self):
with self.assertRaises(ValueError):
PACKER.package(self.root, self.VERSION, self.output)
with zipfile.ZipFile(self.output / f"gitea-mail-templates-{self.VERSION}.zip") as archive:
zipped = {name: archive.read(name) for name in archive.namelist()}
with tarfile.open(self.output / f"gitea-mail-templates-{self.VERSION}.tar.gz") as archive:
tarred = {
member.name: archive.extractfile(member).read()
for member in archive.getmembers()
}
self.assertTrue(all(member.mode == 0o644 for member in archive.getmembers()))
self.assertEqual(expected_members, zipped)
self.assertEqual(expected_members, tarred)
self.assertIn(prefix + "themes/demo/mail/base/head.tmpl", zipped)
self.assertFalse(any("stale" in name or "/build/" in name for name in zipped))
def test_existing_archives_are_not_overwritten(self):
PACKER.package(self.root, self.VERSION, self.output)
original_archives = {
path.name: path.read_bytes() for path in self.output.iterdir()
}
with self.assertRaisesRegex(ValueError, "Refusing to overwrite"):
PACKER.package(self.root, self.VERSION, self.output)
self.assertEqual(original_archives, {
path.name: path.read_bytes() for path in self.output.iterdir()
})
# Validation: release identity, source checksums and generated checksums.
def test_wrong_version_is_rejected(self):
with self.assertRaisesRegex(ValueError, "must match the locked Gitea tag"):
PACKER.collect(self.root, "v28.1.0")
def test_changed_source_is_rejected(self):
self.write("themes/demo/theme.css", b"changed")
with self.assertRaisesRegex(ValueError, "Source changed"):
PACKER.collect(self.root, self.tag)
PACKER.collect(self.root, self.VERSION)
def test_refuses_missing_language_bundle(self):
def test_missing_language_bundle_is_rejected(self):
(self.root / "preview/rendered/en-US.js").unlink()
with self.assertRaises(ValueError):
PACKER.collect(self.root, self.tag)
with self.assertRaisesRegex(ValueError, "Expected regular file"):
PACKER.collect(self.root, self.VERSION)
def test_refuses_modified_generated_files(self):
def test_modified_generated_files_are_rejected(self):
self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
PACKER.collect(self.root, self.tag)
PACKER.collect(self.root, self.VERSION)
def test_includes_nested_documentation_and_release_note_sources(self):
# Documentation: recursive inclusion and optional root files.
def test_nested_documentation_and_release_notes_are_included(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(),
"docs/INDEX.zh-CN.md": "中文索引".encode("utf-8"),
"docs/images/README.zh-CN.md": "截图指南".encode("utf-8"),
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode("utf-8"),
".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)
files = PACKER.collect(self.root, self.VERSION)
for name, data in documents.items():
self.assertEqual(files[name], data)
with self.subTest(document=name):
self.assertEqual(data, files[name])
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"]:
def test_optional_root_documents_may_be_absent(self):
for name in ("AGENTS.md", "THIRD_PARTY_NOTICES.md"):
(self.root / name).unlink()
files = PACKER.collect(self.root, self.tag)
files = PACKER.collect(self.root, self.VERSION)
self.assertNotIn("AGENTS.md", files)
self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
self.assertIn("GITEA-LICENSE", files)
+35 -68
View File
@@ -2,44 +2,17 @@ name: Release
on:
push:
branches: [main]
tags: ['v*']
jobs:
validate:
name: Validate Templates
runs-on: linux-amd64-docker-small
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25.x'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test workflow scripts
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
- name: Verify pinned official snapshot
working-directory: tools
run: |
go run . upstream prepare
go run . upstream verify
- name: Test — framework alignment and multilingual notification parity
working-directory: tools
run: go test ./...
- name: Build and render all themes and official languages
working-directory: tools
run: go run . preview all
name: Validate
uses: ./.github/workflows/validate.yml
permissions:
contents: read
package:
name: Package & Release
if: startsWith(github.ref, 'refs/tags/v')
name: Package
needs: validate
runs-on: linux-amd64-docker-small
permissions:
@@ -57,50 +30,44 @@ jobs:
with:
python-version: '3.11'
- name: Verify release tag matches the official snapshot
- name: Verify tag matches the snapshot
run: |
python -c 'import json, os; lock=json.load(open("gitea.lock.json")); assert lock["tag"] == os.environ["GITHUB_REF_NAME"], "Release tag must match the pinned Gitea tag"'
python - <<'PY'
import json
import os
- name: Verify release notes
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md"
with open("gitea.lock.json", encoding="utf-8") as lock_file:
lock = json.load(lock_file)
assert lock["tag"] == os.environ["GITHUB_REF_NAME"], (
"Release tag must match the pinned Gitea tag"
)
PY
- name: Generate static preview for the archive
- name: Generate preview
working-directory: tools
run: go run . preview all
- name: Package generated installable themes and multilingual preview
- name: Package themes and preview
run: python -B .github/scripts/package_release.py --version "$TEMPLATE_RELEASE"
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}
- name: Create Release
run: python -B .github/scripts/gitea_actions.py publish-release --version "$TEMPLATE_RELEASE"
env:
TEMPLATE_RELEASE: ${{ github.ref_name }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
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: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- name: Prepare release runtime
uses: actions/setup-node@v4
with:
python-version: '3.11'
- 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 }}
node-version: '22'
- name: Release
uses: actions/gitea-release-action@v1.3.7
with:
server_url: ${{ github.server_url }}
repository: ${{ github.repository }}
token: ${{ secrets.GITEA_TOKEN }}
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
body: ''
draft: false
prerelease: false
files: |
dist/gitea-mail-templates-${{ github.ref_name }}.zip
dist/gitea-mail-templates-${{ github.ref_name }}.tar.gz
+42
View File
@@ -0,0 +1,42 @@
name: Validate
on:
push:
branches: ['**']
pull_request:
workflow_call:
permissions:
contents: read
jobs:
validate:
name: Validate
runs-on: linux-amd64-docker-small
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25.x'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test workflow scripts
run: python -B -m unittest discover -s .github/scripts -p 'test_*.py'
- name: Verify snapshot
working-directory: tools
run: |
go run . upstream prepare
go run . upstream verify
- name: Tools test
working-directory: tools
run: go test ./...
- name: Build and render all themes and official languages
working-directory: tools
run: go run . preview all