"""Package verified generated mail overrides; never copy stale build directories.""" import argparse import hashlib import io import json 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", "preview/lib/html2canvas/html2canvas.min.js", "preview/lib/html2canvas/LICENSE", "preview/lib/html2canvas/README.md", ) 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): 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 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 = read_json(root / "gitea.lock.json") if version != lock["tag"]: raise ValueError("Package version must match the locked Gitea tag") 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") 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") 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 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") 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 ARCHIVE_EXTENSIONS] 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 = len(data) info.mode = 0o644 tar.addfile(info, io.BytesIO(data)) print(f"[PASS] Packaged {len(files)} verified files: {targets[0]}, {targets[1]}") 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]) 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") if __name__ == "__main__": main()