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

+171 -63
View File
@@ -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()