diff --git a/.github/release-notes/v1.27.2.md b/.github/release-notes/v1.27.2.md deleted file mode 100644 index 86741ba..0000000 --- a/.github/release-notes/v1.27.2.md +++ /dev/null @@ -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) diff --git a/.github/release-notes/v1.27.3.md b/.github/release-notes/v1.27.3.md deleted file mode 100644 index 972a760..0000000 --- a/.github/release-notes/v1.27.3.md +++ /dev/null @@ -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) diff --git a/.github/release-notes/v28.0.0.md b/.github/release-notes/v28.0.0.md deleted file mode 100644 index 737afc2..0000000 --- a/.github/release-notes/v28.0.0.md +++ /dev/null @@ -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) diff --git a/.github/scripts/gitea_actions.py b/.github/scripts/gitea_actions.py deleted file mode 100644 index 72ad619..0000000 --- a/.github/scripts/gitea_actions.py +++ /dev/null @@ -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"" - 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() diff --git a/.github/scripts/package_release.py b/.github/scripts/package_release.py index 437d282..eecc0de 100644 --- a/.github/scripts/package_release.py +++ b/.github/scripts/package_release.py @@ -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,101 +49,187 @@ 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): - raise ValueError(f"Invalid theme name: {name}") - source = root / "themes" / name - theme = json.loads(read_file(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"]): - raise ValueError(f"Stale build identity: {name}") - if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.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: - raise ValueError(f"Incomplete generated install package: {name}") - for path, expected in meta["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(): - if not path.startswith("mail/") or not path.endswith(".tmpl"): - raise ValueError(f"Unexpected generated file: {path}") - data = read_file(checked_path(built, path)) - if digest(data) != expected: - 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 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 = 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 + 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}") + 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}") + if set(metadata["files"]) != expected_template_paths(root, lock, theme["mode"]): + raise ValueError(f"Incomplete generated install package: {name}") + 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}") + 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)) + if digest(data) != expected: + 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() diff --git a/.github/scripts/test_gitea_actions.py b/.github/scripts/test_gitea_actions.py deleted file mode 100644 index a6ffa69..0000000 --- a/.github/scripts/test_gitea_actions.py +++ /dev/null @@ -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("", 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 = "" - 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": ""}], [], - {"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() diff --git a/.github/scripts/test_package_release.py b/.github/scripts/test_package_release.py index 38fdb36..4118c1b 100644 --- a/.github/scripts/test_package_release.py +++ b/.github/scripts/test_package_release.py @@ -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"
footer
"} - 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": ""}}) + ";").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"footer
", + } + 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": ""}} + 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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f698a75..3feb47d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..cb34bf0 --- /dev/null +++ b/.github/workflows/validate.yml @@ -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