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
+397 -505

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()
+171 -63
View File
@@ -4,10 +4,28 @@ import argparse
import hashlib import hashlib
import io import io
import json import json
from pathlib import Path, PurePosixPath
import re import re
import tarfile import tarfile
import zipfile 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): def read_file(path):
@@ -31,101 +49,187 @@ def digest(data):
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
def collect(root, version): def read_json(path):
root = Path(root) return json.loads(read_file(path))
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
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") 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"]: if version != lock["tag"]:
raise ValueError("Package version must match the locked Gitea tag") raise ValueError("Package version must match the locked Gitea tag")
files = {} return lock
names = sorted(p.name for p in (root / "themes").iterdir() if p.is_dir() and not p.is_symlink())
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: if not names:
raise ValueError("No source theme manifests") raise ValueError("No source theme manifests")
for name in names: return names
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
raise ValueError(f"Invalid theme name: {name}")
source = root / "themes" / name def expected_template_paths(root, lock, mode):
theme = json.loads(read_file(source / "theme.json")) """Shared themes override base files; framed themes also supply all bodies."""
if theme["name"] != name or theme["mode"] not in ("shared", "framed"): paths = set(BASE_TEMPLATES)
raise ValueError(f"Invalid theme metadata: {name}") if mode == "framed":
built = root / "build" / "themes" / name paths.update(
meta = json.loads(read_file(built / "build.json")) path.removeprefix("templates/")
if (meta["theme"], meta["mode"], meta["gitea_tag"], meta["gitea_commit"]) != (name, theme["mode"], version, lock["commit"]): for path in lock["files"]
raise ValueError(f"Stale build identity: {name}") if path.startswith("templates/mail/") and path.endswith(".tmpl")
if not {"theme.json", "theme.css"}.issubset(meta.get("sources") or {}) or not meta.get("files"): )
raise ValueError(f"Missing source/file provenance: {name}") paths.update(
expected_files = {"mail/base/head.tmpl", "mail/base/footer.tmpl"} path.relative_to(root / "framework").as_posix()
if theme["mode"] == "framed": for path in (root / "framework" / "mail").rglob("*.tmpl")
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")) return paths
if set(meta["files"]) != expected_files:
raise ValueError(f"Incomplete generated install package: {name}")
for path, expected in meta["sources"].items(): def collect_theme(root, lock, name):
base = root if path.startswith(("framework/", "tools/")) else source """Accept only a complete build whose identity and checksums still match."""
if digest(read_file(checked_path(base, path))) != expected: if not THEME_NAME_PATTERN.fullmatch(name):
raise ValueError(f"Source changed since build: {name}/{path}") raise ValueError(f"Invalid theme name: {name}")
for path, expected in meta["files"].items(): source = root / "themes" / name
if not path.startswith("mail/") or not path.endswith(".tmpl"): theme = read_json(source / "theme.json")
raise ValueError(f"Unexpected generated file: {path}") if theme["name"] != name or theme["mode"] not in ("shared", "framed"):
data = read_file(checked_path(built, path)) raise ValueError(f"Invalid theme metadata: {name}")
if digest(data) != expected: built = root / "build" / "themes" / name
raise ValueError(f"Generated checksum mismatch: {name}/{path}") metadata = read_json(built / "build.json")
files[f"themes/{name}/{path}"] = data actual_identity = (
files[f"themes/{name}/build.json"] = read_file(built / "build.json") 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") manifest_data = read_file(root / "preview" / "rendered.js")
prefix = b"window.__MAIL_PREVIEW__ = " manifest = parse_script_payload(manifest_data, PREVIEW_MANIFEST_PREFIX)
manifest = json.loads(manifest_data.split(prefix, 1)[1].strip().removesuffix(b";")) languages = sorted(
languages = sorted(path.removeprefix("options/locale/locale_").removesuffix(".json") for path in lock["files"] if path.startswith("options/locale/locale_") and path.endswith(".json")) path.removeprefix("options/locale/locale_").removesuffix(".json")
if manifest["upstream"] != version or manifest.get("upstream_commit") != lock["commit"] or sorted(manifest["themes"]) != names or sorted(manifest["languages"]) != languages: for path in lock["files"]
raise ValueError("Preview does not match lock/all source themes and languages; run preview all") if path.startswith("options/locale/locale_") and path.endswith(".json")
files["preview/rendered.js"] = manifest_data )
for lang in languages: matches_build = (
data = read_file(root / "preview" / "rendered" / (lang + ".js")) manifest["upstream"] == lock["tag"]
marker = ('window.__MAIL_PREVIEW_DATA__[' + json.dumps(lang) + '] = ').encode() and manifest.get("upstream_commit") == lock["commit"]
payload = json.loads(data.split(marker, 1)[1].strip().removesuffix(b";")) and sorted(manifest["themes"]) == theme_names
if sorted(payload) != names or any(set(payload[name]) != set(manifest["registry"]) for name in names): and sorted(manifest["languages"]) == languages
raise ValueError(f"Incomplete preview language bundle: {lang}") )
files[f"preview/rendered/{lang}.js"] = data if not matches_build:
for name in ["LICENSE", "README.md", "CONTRIBUTING.md", "COMPATIBILITY.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]: raise ValueError(
files[name] = read_file(root / name) "Preview does not match lock/all source themes and languages; run preview all"
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]: )
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(): if (root / name).exists():
files[name] = read_file(root / name) files[name] = read_file(root / name)
for directory in [root / "docs", root / ".github" / "release-notes"]: for directory in DOCUMENTATION_DIRECTORIES:
for path in sorted(directory.rglob("*.md")): for path in sorted((root / directory).rglob("*.md")):
name = path.relative_to(root).as_posix() name = path.relative_to(root).as_posix()
files[name] = read_file(checked_path(root, name)) files[name] = read_file(checked_path(root, name))
for path in sorted((root / "docs" / "images").iterdir()): for path in sorted((root / "docs" / "images").iterdir()):
if path.is_file() and (path.suffix == ".png" or path.name == "README.md"): if path.is_file() and (path.suffix == ".png" or path.name == "README.md"):
files["docs/images/" + path.name] = read_file(path) files["docs/images/" + path.name] = read_file(path)
return files
def collect_provenance(root, lock):
license_data = read_file(root / "build" / "upstream" / "LICENSE") license_data = read_file(root / "build" / "upstream" / "LICENSE")
if digest(license_data) != lock["files"]["LICENSE"]: if digest(license_data) != lock["files"]["LICENSE"]:
raise ValueError("Upstream license checksum mismatch") raise ValueError("Upstream license checksum mismatch")
files["GITEA-LICENSE"] = license_data return {
files["upstream-lock.json"] = read_file(root / "gitea.lock.json") "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 return files
def package(root, version, output): def package(root, version, output):
"""Write both archive formats from the same verified file collection."""
files = collect(root, version) files = collect(root, version)
archive = f"gitea-mail-templates-{version}" archive = f"gitea-mail-templates-{version}"
output = Path(output) 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): 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) 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()): for name, data in sorted(files.items()):
path = archive + "/" + name path = archive + "/" + name
zipped.writestr(path, data) zipped.writestr(path, data)
info = tarfile.TarInfo(path) info = tarfile.TarInfo(path)
info.size, info.mode = len(data), 0o644 info.size = len(data)
info.mode = 0o644
tar.addfile(info, io.BytesIO(data)) tar.addfile(info, io.BytesIO(data))
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}") print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
if __name__ == "__main__": def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True) parser.add_argument("--version", required=True)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) 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) package(args.root, args.version, args.output)
except (ValueError, KeyError, IndexError, OSError) as error: except (ValueError, KeyError, IndexError, OSError) as error:
parser.exit(1, f"[FAIL] {error}\n") 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 importlib.util
import json import json
from pathlib import Path import tarfile
import tempfile import tempfile
import unittest import unittest
import zipfile 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) PACKER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKER) SPEC.loader.exec_module(PACKER)
class PackagingTests(unittest.TestCase): 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): def setUp(self):
self.temp = tempfile.TemporaryDirectory() temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup) self.addCleanup(temporary_directory.cleanup)
self.root = Path(self.temp.name) self.root = Path(temporary_directory.name)
self.tag = "v28.0.0" self.output = self.root / "artifacts"
self.commit = "a" * 40 self.create_theme()
theme = json.dumps({"name": "demo", "mode": "shared"}).encode() self.create_snapshot()
css = b"a { color:blue; }" self.create_preview()
self.write("themes/demo/theme.json", theme) for name in self.ROOT_DOCUMENTS:
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"]:
self.write(name, b"test") self.write(name, b"test")
# Fixture helpers keep test bodies focused on the behavior being checked.
def write(self, name, data): def write(self, name, data):
path = self.root / name path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data) path.write_bytes(data)
def write_json(self, name, 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") self.write("build/themes/stale/mail/stale.tmpl", b"stale")
out = self.root / "artifacts" expected_files = PACKER.collect(self.root, self.VERSION)
PACKER.package(self.root, self.tag, out) prefix = f"gitea-mail-templates-{self.VERSION}/"
with zipfile.ZipFile(next(out.glob("*.zip"))) as archive: expected_members = {
names = archive.namelist() prefix + name: data for name, data in expected_files.items()
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)
def test_refuses_stale_source_tampered_output_and_wrong_version(self): PACKER.package(self.root, self.VERSION, self.output)
with self.assertRaises(ValueError):
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") PACKER.collect(self.root, "v28.1.0")
def test_changed_source_is_rejected(self):
self.write("themes/demo/theme.css", b"changed") self.write("themes/demo/theme.css", b"changed")
with self.assertRaisesRegex(ValueError, "Source 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() (self.root / "preview/rendered/en-US.js").unlink()
with self.assertRaises(ValueError): with self.assertRaisesRegex(ValueError, "Expected regular file"):
PACKER.collect(self.root, self.tag) 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") self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
with self.assertRaisesRegex(ValueError, "checksum mismatch"): 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 = { documents = {
"docs/INDEX.zh-CN.md": "中文索引".encode(), "docs/INDEX.zh-CN.md": "中文索引".encode("utf-8"),
"docs/images/README.zh-CN.md": "截图指南".encode(), "docs/images/README.zh-CN.md": "截图指南".encode("utf-8"),
"docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode(), "docs/release-notes/v28.0.0.zh-CN.md": "发布说明".encode("utf-8"),
".github/release-notes/v28.0.0.md": b"English release notes", ".github/release-notes/v28.0.0.md": b"English release notes",
} }
for name, data in documents.items(): for name, data in documents.items():
self.write(name, data) self.write(name, data)
self.write("docs/private.tmp", b"not documentation") 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(): 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.assertIn("AGENTS.md", files)
self.assertNotIn("docs/private.tmp", files) self.assertNotIn("docs/private.tmp", files)
def test_packages_without_removed_optional_root_documents(self): def test_optional_root_documents_may_be_absent(self):
for name in ["AGENTS.md", "THIRD_PARTY_NOTICES.md"]: for name in ("AGENTS.md", "THIRD_PARTY_NOTICES.md"):
(self.root / name).unlink() (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("AGENTS.md", files)
self.assertNotIn("THIRD_PARTY_NOTICES.md", files) self.assertNotIn("THIRD_PARTY_NOTICES.md", files)
self.assertIn("GITEA-LICENSE", files) self.assertIn("GITEA-LICENSE", files)
+35 -68
View File
@@ -2,44 +2,17 @@ name: Release
on: on:
push: push:
branches: [main]
tags: ['v*'] tags: ['v*']
jobs: jobs:
validate: validate:
name: Validate Templates name: Validate
runs-on: linux-amd64-docker-small uses: ./.github/workflows/validate.yml
steps: permissions:
- uses: actions/checkout@v4 contents: read
- 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
package: package:
name: Package & Release name: Package
if: startsWith(github.ref, 'refs/tags/v')
needs: validate needs: validate
runs-on: linux-amd64-docker-small runs-on: linux-amd64-docker-small
permissions: permissions:
@@ -57,50 +30,44 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Verify release tag matches the official snapshot - name: Verify tag matches the snapshot
run: | 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 with open("gitea.lock.json", encoding="utf-8") as lock_file:
run: test -s ".github/release-notes/${GITHUB_REF_NAME}.md" 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 working-directory: tools
run: go run . preview all 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" run: python -B .github/scripts/package_release.py --version "$TEMPLATE_RELEASE"
env: env:
TEMPLATE_RELEASE: ${{ github.ref_name }} TEMPLATE_RELEASE: ${{ github.ref_name }}
- name: Create Release - name: Prepare release runtime
run: python -B .github/scripts/gitea_actions.py publish-release --version "$TEMPLATE_RELEASE" uses: actions/setup-node@v4
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
with: with:
python-version: '3.11' node-version: '22'
- name: Create documentation reminder Issue
run: python -B .github/scripts/gitea_actions.py create-issue --version "$TEMPLATE_RELEASE" - name: Release
env: uses: actions/gitea-release-action@v1.3.7
TEMPLATE_RELEASE: ${{ github.ref_name }} with:
GITEA_SERVER_URL: ${{ github.server_url }} server_url: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }} repository: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} 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