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

+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)