"""Package and publish the last successful build to the SMB update directory.""" import argparse import hashlib import json import os from pathlib import Path import re import shutil import tempfile import zipfile PROJECT_ROOT = Path(__file__).resolve().parent.parent BUILD_DIR = PROJECT_ROOT / "2_build" DEPLOY_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren") EXE_NAME = "Tätigkeitsnachweis-Generator.exe" MANIFEST_NAME = "release.json" PACKAGE_FILES = [ EXE_NAME, "README.md", "CHANGELOG.md", "config/settings.ini", "config/input-fields.json", "config/output-fields.json", ] def is_template_pdf(path: Path) -> bool: """Nur *.pdf, die nicht mit '_' oder '.' beginnen, gelten als Template.""" return path.is_file() and path.suffix.lower() == ".pdf" and not path.name.startswith(("_", ".")) def template_files(base_dir: Path) -> list[str]: """Relative Pfade aller Template-PDFs in base_dir/template (sortiert).""" folder = base_dir / "template" if not folder.is_dir(): return [] return [f"template/{p.name}" for p in sorted(folder.iterdir()) if is_template_pdf(p)] def package_files(build_dir: Path) -> list[str]: """Alle Dateien des Release-Pakets: feste Dateien plus alle Template-PDFs.""" return PACKAGE_FILES + template_files(build_dir) def normalize_version(version: str) -> str: if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version): raise ValueError(f"Ungültige Versionsnummer: {version}") return ".".join(str(int(part)) for part in (version.split(".") + ["0"])[:3]) def file_hash(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def write_release_manifest(build_dir: Path, version: str) -> None: """Record the version and all payload hashes after a successful build.""" manifest = { "version": normalize_version(version), "files": {name: file_hash(build_dir / name) for name in package_files(build_dir)}, } (build_dir / MANIFEST_NAME).write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def validate_release(build_dir: Path) -> str: if not (build_dir / MANIFEST_NAME).is_file(): raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.") manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8")) version = normalize_version(manifest["version"]) for name in package_files(build_dir): path = build_dir / name if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name): raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.") return version def check_deploy_target(target_dir: Path) -> None: """Never create the SMB destination: a missing mount must be an error.""" try: if not target_dir.is_dir(): raise OSError("Verzeichnis ist nicht verfügbar") # Actually access the directory; exists() alone does not check read access. with os.scandir(target_dir) as entries: next(entries, None) except OSError as exc: raise OSError( f"Deployment-Ziel nicht erreichbar: {target_dir}\n" "Bitte VPN-Verbindung und Netzlaufwerk J: prüfen und deploy.bat erneut starten." ) from exc def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path: version = validate_release(build_dir) check_deploy_target(target_dir) archive_name = f"Tätigkeitsnachweis-Generator v{version}.zip" archive = build_dir / archive_name descriptor, temporary = tempfile.mkstemp(prefix="release-", suffix=".zip", dir=build_dir) os.close(descriptor) temporary = Path(temporary) try: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package: for name in package_files(build_dir): package.write(build_dir / name, name) for directory in ["input/", "output/"]: # Fixed timestamps keep repeated deployments byte-identical. package.writestr(zipfile.ZipInfo(directory, date_time=(2020, 1, 1, 0, 0, 0)), b"") with zipfile.ZipFile(temporary) as package: if package.testzip() is not None: raise ValueError("Das erzeugte ZIP-Paket ist beschädigt.") temporary.replace(archive) finally: temporary.unlink(missing_ok=True) destination = target_dir / archive_name if destination.exists(): if file_hash(destination) == file_hash(archive): print(f"[OK] Diese Version ist bereits unverändert bereitgestellt: {destination}") return destination raise FileExistsError(f"{destination} existiert bereits mit anderem Inhalt. Bitte eine neue Version bauen.") # Write access is checked by creating the staging file on the actual share. remote_temporary = None try: descriptor, name = tempfile.mkstemp(prefix=".upload-", suffix=".tmp", dir=target_dir) os.close(descriptor) remote_temporary = Path(name) shutil.copyfile(archive, remote_temporary) if file_hash(remote_temporary) != file_hash(archive): raise OSError("Die Prüfsumme nach der SMB-Übertragung stimmt nicht überein.") # On Windows rename refuses to overwrite another published release. remote_temporary.rename(destination) except OSError as exc: raise OSError( "Deployment fehlgeschlagen. VPN-Verbindung und Schreibrechte prüfen. " "Der erfolgreiche lokale Build bleibt erhalten; erneut mit deploy.bat starten." ) from exc finally: if remote_temporary is not None: try: remote_temporary.unlink(missing_ok=True) except OSError: pass # The VPN may have disconnected during transfer. print(f"[OK] Deployment abgeschlossen: {destination}") return destination def main() -> int: parser = argparse.ArgumentParser(description="Letzten erfolgreichen Build auf dem SMB-Laufwerk bereitstellen") parser.parse_args() try: deploy() except (OSError, ValueError, KeyError) as exc: print(f"[FEHLER] {exc}") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())