Alle *.pdf im template-Ordner werden ausgeliefert; Dateien mit '_' oder '.' am Anfang sowie andere Dateitypen werden ignoriert. Veraltete PDFs im Build-Ordner werden entfernt. Tests entsprechend angepasst. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
161 lines
6.4 KiB
Python
161 lines
6.4 KiB
Python
"""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())
|