diff --git a/.gitea/workflows/legal-manifest.yml b/.gitea/workflows/legal-manifest.yml new file mode 100644 index 0000000..6509462 --- /dev/null +++ b/.gitea/workflows/legal-manifest.yml @@ -0,0 +1,47 @@ +name: Build legal manifest + +# HT-14: renders every project's legal markdown to HTML+PDF and produces +# dist/legal-manifest.json — the single canonical source HT-15's consumer backends import, +# so no one hashes/versions independently. See scripts/build-manifest.py for the actual +# logic; this workflow just provides the environment it needs. + +on: + push: + branches: [main] + workflow_dispatch: {} + +jobs: + build-manifest: + runs-on: ubuntu-latest + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + # NOT the default shallow clone: scripts/build-manifest.py's per-file + # `git log -1 -- path` needs real history, or every file silently resolves to + # whatever the one shallow commit happens to be (see HT-15 belépő ellenőrzés). + fetch-depth: 0 + + # The CI environment: a plain ubuntu-latest job container (act_runner default image), + # with pandoc (markdown -> HTML, per HT-14) and WeasyPrint (HTML -> PDF, same + # approach already proven deterministic for ResidentFirst's HRF-34 offer contracts) + # installed as an explicit step — not baked into a custom runner image, so the + # environment stays visible and reproducible directly from this file. + - name: Install pandoc + WeasyPrint + run: | + apt-get update -qq + apt-get install -y --no-install-recommends \ + pandoc python3 python3-pip python3-venv \ + libpango-1.0-0 libpangoft2-1.0-0 fonts-dejavu-core + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet weasyprint==69.0 + echo "/tmp/venv/bin" >> "$GITHUB_PATH" + + - name: Build manifest + run: python3 scripts/build-manifest.py + + - name: Upload manifest + rendered artifacts + uses: actions/upload-artifact@v4 + with: + name: legal-manifest + path: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21faee5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +__pycache__/ +*.pyc diff --git a/scripts/build-manifest.py b/scripts/build-manifest.py new file mode 100644 index 0000000..39da408 --- /dev/null +++ b/scripts/build-manifest.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Builds dist/legal-manifest.json (HT-14): renders each project's markdown legal documents +to HTML+PDF and computes a content_hash, producing the single canonical manifest every +consumer backend imports at deploy time (HT-15) instead of hashing independently. + +content_hash source (interim, per the HT-3 "Design döntés" comment): the source markdown's +git blob hash (`git hash-object`), NOT the rendered artifact's hash yet — that requires a +proven byte-for-byte-deterministic render, which this pandoc+WeasyPrint combination hasn't +been tested for (ResidentFirst's HRF-34 proved a *different* render pipeline deterministic, +not this one). Switching later doesn't invalidate old receipts, since a receipt is bound to +document_version, not content_hash alone. + +effective_from source: parsed from each document's own "Hatálybalépés napja: " line — +the one human-controlled field carrying the legally relevant date. A document whose date is +still the unfilled "[dátum]" placeholder gets effective_from: null (not yet effective) +rather than an invented date. + +version: the short hash of the last commit that actually touched this file (NOT repo HEAD — +see HT-15's belépő ellenőrzés) — same convention as the two consumer sync scripts already +use for document_version. + +Usage: python3 scripts/build-manifest.py +""" + +import json +import re +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_DIR = REPO_ROOT / "dist" + +HUNGARIAN_MONTHS = { + "január": 1, "február": 2, "március": 3, "április": 4, "május": 5, "június": 6, + "július": 7, "augusztus": 8, "szeptember": 9, "október": 10, "november": 11, "december": 12, +} + +# e.g. "*Hatálybalépés napja: 2026. július 17. · Verzió: 1.0*" +DATE_LINE_RE = re.compile(r"Hatálybalépés napja:\s*(.+?)\s*·\s*Verzió:") +HU_DATE_RE = re.compile(r"(\d{4})\.\s*([a-záéíóöőúüű]+)\s*(\d{1,2})\.") + + +def parse_effective_from(markdown: str) -> str | None: + m = DATE_LINE_RE.search(markdown) + if not m: + return None + hu = HU_DATE_RE.match(m.group(1).strip()) + if not hu: + return None # still the "[dátum]" placeholder, or an unrecognized format + year, month_name, day = hu.groups() + month = HUNGARIAN_MONTHS.get(month_name.lower()) + if month is None: + return None + return f"{int(year):04d}-{month:02d}-{int(day):02d}" + + +def _git(*args: str) -> str: + return subprocess.run( + ["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True + ).stdout.strip() + + +def git_blob_hash(path: Path) -> str: + return _git("hash-object", str(path)) + + +def last_commit_hash(path: Path) -> str: + relative = path.relative_to(REPO_ROOT) + # NOTE: requires full history (fetch-depth: 0 in CI) — a shallow clone would silently + # resolve every file to the same single available commit. See HT-15. + return _git("log", "-1", "--format=%h", "--", str(relative)) + + +def render_html(markdown_path: Path, html_path: Path) -> None: + subprocess.run( + ["pandoc", str(markdown_path), "-o", str(html_path), "--standalone", "--metadata", "lang=hu"], + check=True, + ) + + +def render_pdf(html_path: Path, pdf_path: Path) -> None: + from weasyprint import HTML + + # full_fonts=True: WeasyPrint's default font subsetting is not byte-for-byte + # deterministic (confirmed empirically while building HRF-34) — full embedding is. + HTML(filename=str(html_path)).write_pdf(str(pdf_path), full_fonts=True) + + +def build_manifest_for_project(project_dir: Path) -> dict: + manifest = {} + for md_path in sorted(project_dir.glob("*.md")): + doc_type = md_path.stem + markdown = md_path.read_text(encoding="utf-8") + + out_dir = OUTPUT_DIR / project_dir.name + out_dir.mkdir(parents=True, exist_ok=True) + html_path = out_dir / f"{doc_type}.html" + pdf_path = out_dir / f"{doc_type}.pdf" + + render_html(md_path, html_path) + render_pdf(html_path, pdf_path) + + manifest[doc_type] = { + "version": last_commit_hash(md_path), + "effective_from": parse_effective_from(markdown), + "content_hash": git_blob_hash(md_path), + "html": f"{project_dir.name}/{doc_type}.html", + "pdf": f"{project_dir.name}/{doc_type}.pdf", + } + return manifest + + +def main() -> None: + OUTPUT_DIR.mkdir(exist_ok=True) + full_manifest = {} + for project_dir in sorted(REPO_ROOT.iterdir()): + if not project_dir.is_dir() or project_dir.name.startswith(".") or project_dir.name in ("dist", "scripts"): + continue + if not list(project_dir.glob("*.md")): + continue + print(f"Renderelés: {project_dir.name}/") + full_manifest[project_dir.name] = build_manifest_for_project(project_dir) + + manifest_path = OUTPUT_DIR / "legal-manifest.json" + manifest_path.write_text(json.dumps(full_manifest, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Manifest írva: {manifest_path}") + + +if __name__ == "__main__": + main()