chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
@@ -0,0 +1,130 @@
|
||||
"""Package verified generated mail overrides; never copy stale build directories."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
|
||||
def read_file(path):
|
||||
path = Path(path)
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError(f"Expected regular file: {path}")
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def checked_path(base, name):
|
||||
parts = PurePosixPath(name)
|
||||
if parts.is_absolute() or ".." in parts.parts or "\\" in name or ":" in name:
|
||||
raise ValueError(f"Unsafe package path: {name}")
|
||||
path = base.joinpath(*parts.parts)
|
||||
if not path.resolve().is_relative_to(base.resolve()):
|
||||
raise ValueError(f"Path escapes package root: {name}")
|
||||
return path
|
||||
|
||||
|
||||
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):
|
||||
raise ValueError("Expected a stable vX.Y.Z package version")
|
||||
lock = json.loads(read_file(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())
|
||||
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")
|
||||
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", "THIRD_PARTY_NOTICES.md", "docs/README.zh-CN.md", "docs/CONTRIBUTING.zh-CN.md", "preview/index.html"]:
|
||||
files[name] = read_file(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)
|
||||
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 files
|
||||
|
||||
|
||||
def package(root, version, output):
|
||||
files = collect(root, version)
|
||||
archive = f"gitea-mail-templates-{version}"
|
||||
output = Path(output)
|
||||
targets = [output / (archive + ext) for ext in (".zip", ".tar.gz")]
|
||||
if any(path.exists() for path in targets):
|
||||
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:
|
||||
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
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
||||
parser.add_argument("--output", type=Path, default=Path("dist"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
package(args.root, args.version, args.output)
|
||||
except (ValueError, KeyError, IndexError, OSError) as error:
|
||||
parser.exit(1, f"[FAIL] {error}\n")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Offline artifact/provenance tests independent of generated repository output."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).with_name("package_release.py"))
|
||||
PACKER = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(PACKER)
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
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", "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")
|
||||
|
||||
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())
|
||||
|
||||
def test_packages_declared_outputs_only_and_never_overwrites(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)
|
||||
|
||||
def test_refuses_stale_source_tampered_output_and_wrong_version(self):
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.collect(self.root, "v28.1.0")
|
||||
self.write("themes/demo/theme.css", b"changed")
|
||||
with self.assertRaisesRegex(ValueError, "Source changed"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
|
||||
def test_refuses_missing_language_bundle(self):
|
||||
(self.root / "preview/rendered/en-US.js").unlink()
|
||||
with self.assertRaises(ValueError):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
|
||||
def test_refuses_modified_generated_files(self):
|
||||
self.write("build/themes/demo/mail/base/head.tmpl", b"modified")
|
||||
with self.assertRaisesRegex(ValueError, "checksum mismatch"):
|
||||
PACKER.collect(self.root, self.tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user