diff --git a/.gitignore b/.gitignore index 35d65a9..cb9041e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ $RECYCLE.BIN/ *.vsix 3_Archiv 2_build + +# Python-Test- und Build-Artefakte +__pycache__/ +.pytest_cache/ diff --git a/0_application/CHANGELOG.md b/0_application/CHANGELOG.md index 6ad38aa..d9e4687 100644 --- a/0_application/CHANGELOG.md +++ b/0_application/CHANGELOG.md @@ -7,6 +7,22 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). --- +## [1.3.2] – 2026-10-07 + +### Behoben + +- EXE erkennt laufende Entwicklungsinstanz und fragt vor dem Neustart + +--- + +## [1.3.1] – 2026-10-07 + +### Behoben + +- Python script erkennt jetzt laufende Entwicklungsinstanz und fragt vor dem Neustart + +--- + ## [1.3] – 2026-08-11 ### Hinzugefügt diff --git a/0_application/README-Development.md b/0_application/README-Development.md index 829e717..e127ec7 100644 --- a/0_application/README-Development.md +++ b/0_application/README-Development.md @@ -103,7 +103,8 @@ Per Doppelklick bleibt das Ergebnisfenster geöffnet. Für automatisierte Aufruf Dieser Aufruf erfolgt aus dem Projekt-Hauptordner. Bei fehlgeschlagenen Tests liefert das Skript einen Fehlercode und führt die übrigen Tests trotzdem aus. -Die Tests laufen nicht automatisch beim Programmstart oder Build. +Beim Build laufen die Tests automatisch vor der Versionierung und dem Bauen. +Beim Programmstart werden sie nicht ausgeführt. End-to-End-Tests parsen die feste Testdatei (`tests/fixture/2026 JM.xlsx`) und das PDF-Template (`template/Tätigkeitsnachweise Stellenförderung.pdf`) und validieren @@ -124,3 +125,55 @@ Die Tests prüfen: von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil, Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten - Neustartlogik unter Windows mit simulierten Prozessen + +## Build und Deployment unter Windows + +Alle folgenden Befehle werden im Projekt-Hauptordner ausgeführt: + +```powershell +# Interaktiv: Versionsänderung auswählen +.\1_build-pipeline\build.bat + +# Bugfix-Version mit Changelog-Beschreibung +.\1_build-pipeline\build.bat --bump patch --message "Fehler beim Programmstart behoben" + + +# Aktuelle Version mit aktualisiertem Changelog erneut als EXE bauen +.\1_build-pipeline\build.bat --no-bump --no-deploy + +# Nur den letzten erfolgreichen Build bereitstellen (kein neuer Build) +.\1_build-pipeline\deploy.bat + +# Deployment ohne abschließende Tasteneingabe +.\1_build-pipeline\deploy.bat --no-pause +``` + +Die Pipeline führt zuerst alle Tests aus. Bei einem Testfehler bleiben Version +und Changelog unverändert; Build und Deployment werden nicht gestartet. +Nach erfolgreichen Tests aktualisiert sie die Version, erstellt die EXE und +legt sie mit Konfiguration, PDF-Template, README und Changelog in `2_build/` ab. +`release.json` hält die Version und Prüfsummen des erfolgreichen Builds fest. +Bei einem Buildfehler wird der Changelog zurückgesetzt. + +Danach erzeugt das Deployment `Tätigkeitsnachweis-Generator vX.Y.Z.zip` und +überträgt es an: + +```text +j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\ +``` + +Das Ziel muss bereits vorhanden und über VPN/SMB erreichbar sein. Ist es nicht +erreichbar oder fehlen Schreibrechte, bricht das Deployment verständlich ab. +Der lokale Build bleibt erhalten. Nach Wiederherstellung der VPN-Verbindung +kann `deploy.bat` die Bereitstellung ohne erneutes Testen, Bauen oder Erhöhen +der Versionsnummer wiederholen. Das Skript akzeptiert nur einen bestätigten, +unveränderten Build mit `release.json`; ältere Builds müssen einmal neu gebaut +werden. + +Das ZIP enthält leere `input/`- und `output/`-Ordner; Arbeitsdateien und +Testdaten werden nicht mit ausgeliefert. Es wird zunächst als temporäre Datei +übertragen, per Prüfsumme geprüft und erst dann unter dem Versionsnamen +sichtbar gemacht. Bestehende Versionen mit anderem Inhalt werden nicht +überschrieben. Ein identisches Paket kann erneut bereitgestellt werden. +Die Neustarttests simulieren Prozesse und Dialogantworten; ein manueller +Starttest der fertigen EXE bleibt vor der Weitergabe sinnvoll. diff --git a/0_application/server_lifecycle.py b/0_application/server_lifecycle.py index c3cde27..724a24d 100644 --- a/0_application/server_lifecycle.py +++ b/0_application/server_lifecycle.py @@ -3,6 +3,7 @@ import json import os import subprocess +import sys from pathlib import Path _CONFIRM_SCRIPT = r''' @@ -25,9 +26,18 @@ def stop_existing_server(port: int = 8501) -> bool: if os.name != "nt": raise RuntimeError("Der kontrollierte Server-Neustart unterstützt nur Windows.") - app_path = str(Path(__file__).resolve().with_name("app.py")) + app_path = Path(__file__).resolve().with_name("app.py") + app_paths = [str(app_path)] + if getattr(sys, "frozen", False): + # A locally built EXE in 2_build/ must also recognize the sibling + # development application, whose path differs from _MEIPASS/app.py. + exe_dir = Path(sys.executable).resolve().parent + development_app = exe_dir.parent / "0_application" / "app.py" + if exe_dir.name.lower() == "2_build" and development_app.is_file(): + app_paths.append(str(development_app)) # Pass paths through the environment, never interpolate them into shell code. - env = dict(os.environ, APP_RESTART_PATH=app_path, APP_RESTART_PID=str(os.getpid()), + env = dict(os.environ, APP_RESTART_PATH=str(app_path), + APP_RESTART_PATHS=json.dumps(app_paths), APP_RESTART_PID=str(os.getpid()), APP_RESTART_PORT=str(port), APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts")) script = _CONFIRM_SCRIPT + r''' @@ -47,11 +57,15 @@ foreach ($processId in $processIds) { if (-not $process) { continue } $isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe' $command = $process.CommandLine - $appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' + - [regex]::Escape($env:APP_RESTART_PATH) + '"?(?:\s|$)' + $matchesApp = $false + foreach ($appPath in ($env:APP_RESTART_PATHS | ConvertFrom-Json)) { + $appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' + + [regex]::Escape($appPath) + '"?(?:\s|$)' + if ($command -match $appArgument) { $matchesApp = $true } + } $isDev = $process.Name -match '^(python(?:w)?(?:\d+(?:\.\d+)*)?(?:w)?|streamlit)\.exe$' -and $command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+' -and - ($command -match $appArgument -or + ($matchesApp -or ($command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?app\.py"?(?:\s|$)' -and [IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV)) if (-not ($isExe -or $isDev)) { diff --git a/0_application/test.command b/0_application/test.command index 5ba4365..f19d28b 100644 --- a/0_application/test.command +++ b/0_application/test.command @@ -27,7 +27,7 @@ echo "" echo "[INFO] Führe End-to-End-Tests aus…" echo "" -"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v +"$VENV_DIR/bin/python" -m pytest tests -v echo "" read -p "Drücke Enter zum Beenden..." diff --git a/0_application/tests/test_build_pipeline.py b/0_application/tests/test_build_pipeline.py new file mode 100644 index 0000000..6c415b4 --- /dev/null +++ b/0_application/tests/test_build_pipeline.py @@ -0,0 +1,185 @@ +"""Verify pipeline gates and deployment against temporary local directories.""" + +import importlib.util +from pathlib import Path +import sys +from unittest.mock import patch, Mock +import zipfile + +import pytest + +PIPELINE_DIR = Path(__file__).resolve().parents[2] / "1_build-pipeline" +sys.path.insert(0, str(PIPELINE_DIR)) +import deploy + +spec = importlib.util.spec_from_file_location("build_pipeline", PIPELINE_DIR / "build.py") +build = importlib.util.module_from_spec(spec) +spec.loader.exec_module(build) + + +@pytest.fixture +def release(tmp_path): + directory = tmp_path / "build" + directory.mkdir() + for name in deploy.PACKAGE_FILES: + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"test release payload") + deploy.write_release_manifest(directory, "1.3") + target = tmp_path / "share" + target.mkdir() + return directory, target + + +def test_deployment_packages_release_without_work_files(release): + directory, target = release + for folder in ["input", "output"]: + (directory / folder).mkdir() + (directory / folder / "private.xlsx").write_bytes(b"private") + result = deploy.deploy(directory, target) + assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip" + with zipfile.ZipFile(result) as archive: + assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + ["input/", "output/"]) + assert archive.testzip() is None + assert not list(target.glob(".upload-*")) + # An unchanged release can be retried without overwriting the published ZIP. + original_time = result.stat().st_mtime_ns + assert deploy.deploy(directory, target) == result + assert result.stat().st_mtime_ns == original_time + + +def test_missing_vpn_target_is_not_created(release): + directory, target = release + missing = target / "missing-share" + with pytest.raises(OSError, match="VPN"): + deploy.deploy(directory, missing) + assert not missing.exists() + assert not list(directory.glob("*.zip")) + + +def test_changed_payload_is_not_deployed(release): + directory, target = release + (directory / deploy.EXE_NAME).write_bytes(b"different executable") + with pytest.raises(ValueError, match="verändert"): + deploy.deploy(directory, target) + assert not list(target.iterdir()) + + +def test_unconfirmed_old_build_is_not_deployed(release): + directory, target = release + (directory / deploy.MANIFEST_NAME).unlink() + with pytest.raises(ValueError, match="bestätigter Build"): + deploy.deploy(directory, target) + + +def test_existing_different_release_is_preserved(release): + directory, target = release + existing = target / "Tätigkeitsnachweis-Generator v1.3.0.zip" + existing.write_bytes(b"already published") + with pytest.raises(FileExistsError, match="anderem Inhalt"): + deploy.deploy(directory, target) + assert existing.read_bytes() == b"already published" + + +def test_interrupted_upload_is_not_published(release): + directory, target = release + with patch.object(deploy.shutil, "copyfile", side_effect=OSError("VPN disconnected")): + with pytest.raises(OSError, match="lokale Build bleibt erhalten"): + deploy.deploy(directory, target) + assert not list(target.iterdir()) + assert list(directory.glob("*.zip")) + assert deploy.validate_release(directory) == "1.3.0" + + +def test_corrupt_upload_is_not_published(release): + directory, target = release + def corrupt_copy(source, destination): + Path(destination).write_bytes(b"corrupted") + with patch.object(deploy.shutil, "copyfile", side_effect=corrupt_copy): + with pytest.raises(OSError): + deploy.deploy(directory, target) + assert not list(target.iterdir()) + + +@pytest.fixture +def pipeline(tmp_path, monkeypatch): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("## [1.3]\n\nOriginal release\n", encoding="utf-8") + version_file = tmp_path / ".last_version" + version_file.write_text("1.3", encoding="utf-8") + monkeypatch.setattr(build, "CHANGELOG_PATH", changelog) + monkeypatch.setattr(build, "VERSION_FILE", version_file) + monkeypatch.setattr(build, "BUILD_OUTPUT_DIR", tmp_path) + monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--message", "Fix"]) + events = [] + names = ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller", + "_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"] + mocks = {} + for name in names: + mocks[name] = Mock(side_effect=lambda *args, _name=name: events.append(_name)) + monkeypatch.setattr(build, name, mocks[name]) + return changelog, version_file, events, mocks + + +def test_pipeline_tests_build_then_deploy(pipeline): + changelog, version_file, events, mocks = pipeline + build.main() + assert events == ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller", + "_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"] + assert version_file.read_text() == "1.3.1" + assert "## [1.3.1]" in changelog.read_text(encoding="utf-8") + + +def test_failing_tests_prevent_version_change_build_and_deploy(pipeline): + changelog, version_file, events, mocks = pipeline + original = changelog.read_bytes() + mocks["_run_tests"].side_effect = SystemExit(1) + with pytest.raises(SystemExit): + build.main() + assert changelog.read_bytes() == original + assert version_file.read_text() == "1.3" + mocks["_run_pyinstaller"].assert_not_called() + mocks["deploy"].assert_not_called() + + +def test_build_failure_rolls_back_changelog_and_prevents_deployment(pipeline): + changelog, version_file, events, mocks = pipeline + original = changelog.read_text(encoding="utf-8") + mocks["_run_pyinstaller"].side_effect = SystemExit(1) + with pytest.raises(SystemExit): + build.main() + assert changelog.read_text(encoding="utf-8") == original + assert version_file.read_text() == "1.3" + mocks["deploy"].assert_not_called() + mocks["_cleanup"].assert_called_once() + + +def test_deploy_failure_keeps_successful_build_version(pipeline): + changelog, version_file, events, mocks = pipeline + mocks["deploy"].side_effect = OSError("VPN unavailable") + with pytest.raises(SystemExit): + build.main() + assert version_file.read_text() == "1.3.1" + assert "## [1.3.1]" in changelog.read_text(encoding="utf-8") + mocks["write_release_manifest"].assert_called_once() + + +def test_dry_run_does_not_test_build_or_deploy(pipeline, monkeypatch): + changelog, version_file, events, mocks = pipeline + monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--dry-run"]) + build.main() + assert not events + assert version_file.read_text() == "1.3" + + +def test_rebuild_preserves_changelog_and_version(pipeline, monkeypatch): + changelog, version_file, events, mocks = pipeline + original = changelog.read_bytes() + monkeypatch.setattr(sys, "argv", ["build.py", "--no-bump", "--no-deploy"]) + build.main() + assert changelog.read_bytes() == original + assert version_file.read_text() == "1.3" + mocks["write_release_manifest"].assert_called_once_with(build.BUILD_OUTPUT_DIR, "1.3") + mocks["_run_tests"].assert_called_once() + mocks["_run_pyinstaller"].assert_called_once() + mocks["deploy"].assert_not_called() diff --git a/0_application/tests/test_server_lifecycle.py b/0_application/tests/test_server_lifecycle.py index 37368b8..8be9ef4 100644 --- a/0_application/tests/test_server_lifecycle.py +++ b/0_application/tests/test_server_lifecycle.py @@ -13,14 +13,18 @@ import server_lifecycle @unittest.skipUnless(os.name == "nt", "Windows process management") class ServerLifecycleTests(unittest.TestCase): - def simulate(self, process, listening=True, stop_error=False, confirm=True): + def simulate(self, process, listening=True, stop_error=False, confirm=True, frozen=False): captured = {} def capture(command, **kwargs): captured.update(script=command[-1], env=kwargs["env"]) return subprocess.CompletedProcess(command, 0, "[]", "") - with patch.object(server_lifecycle.subprocess, "run", side_effect=capture): + root = Path(__file__).resolve().parents[2] + with patch.object(server_lifecycle.subprocess, "run", side_effect=capture), \ + patch.object(server_lifecycle.sys, "frozen", frozen, create=True), \ + patch.object(server_lifecycle.sys, "executable", str(root / "2_build" / "Tätigkeitsnachweis-Generator.exe")), \ + patch.object(server_lifecycle, "__file__", str(root / "_MEI-test" / "server_lifecycle.py") if frozen else server_lifecycle.__file__): server_lifecycle.stop_existing_server() setup = r''' @@ -140,6 +144,32 @@ function Confirm-ServerRestart { self.assertNotIn("CONFIRM_CALLED", result.stderr) self.assertNotIn("STOP_CALLED", result.stderr) + def test_exe_recognizes_sibling_development_server(self): + process = r'''[pscustomobject]@{ + Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe'; + CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' + + (($env:APP_RESTART_PATHS | ConvertFrom-Json)[1]) + + '" --server.port=8501 --server.address=localhost') + }''' + for answer, expected_code in [(True, 0), (False, 2)]: + with self.subTest(confirm=answer): + result = self.simulate(process, frozen=True, confirm=answer) + self.assertEqual(result.returncode, expected_code, result.stderr) + self.assertIn("CONFIRM_CALLED", result.stderr) + if answer: + self.assertIn("STOP_CALLED", result.stderr) + else: + self.assertNotIn("STOP_CALLED", result.stderr) + + def test_exe_preserves_unrelated_development_server(self): + result = self.simulate(r'''[pscustomobject]@{ + Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe'; + CommandLine='python -m streamlit run "C:\other-project\app.py"' + }''', frozen=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("CONFIRM_CALLED", result.stderr) + self.assertNotIn("STOP_CALLED", result.stderr) + def test_unrelated_server_is_preserved(self): result = self.simulate(r'''[pscustomobject]@{ Name='python.exe'; CommandLine='streamlit run app.py'; diff --git a/1_build-pipeline/.last_version b/1_build-pipeline/.last_version index a58941b..d5e98f7 100644 --- a/1_build-pipeline/.last_version +++ b/1_build-pipeline/.last_version @@ -1 +1 @@ -1.3 \ No newline at end of file +1.3.2 \ No newline at end of file diff --git a/1_build-pipeline/build.bat b/1_build-pipeline/build.bat index ce543ab..6ebda3a 100644 --- a/1_build-pipeline/build.bat +++ b/1_build-pipeline/build.bat @@ -37,11 +37,11 @@ REM ── Build ausführen ───────────────── if errorlevel 1 ( echo. - echo [FEHLER] Build fehlgeschlagen! + echo [FEHLER] Pipeline fehlgeschlagen. Details stehen oben. pause exit /b 1 ) echo. -echo Build abgeschlossen. +echo Tests, Build und Deployment abgeschlossen. pause diff --git a/1_build-pipeline/build.py b/1_build-pipeline/build.py index 93a0f71..e0f6d3b 100644 --- a/1_build-pipeline/build.py +++ b/1_build-pipeline/build.py @@ -2,7 +2,8 @@ Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß Benutzereingabe (major/minor/patch), aktualisiert das Changelog -und baut via PyInstaller eine standalone .exe nach 2_build/. +führt zuerst alle Tests aus, baut via PyInstaller eine standalone .exe +nach 2_build/ und stellt sie anschließend als ZIP auf dem SMB-Laufwerk bereit. Verwendung: python build.py # interaktiver Modus @@ -20,6 +21,8 @@ from datetime import date from pathlib import Path from typing import Optional +from deploy import deploy, write_release_manifest + # ── Pfade ─────────────────────────────────────────────────────── BUILD_PIPELINE_DIR = Path(__file__).resolve().parent PROJECT_ROOT = BUILD_PIPELINE_DIR.parent @@ -177,6 +180,21 @@ def _build_sections(bump_type: str, message: Optional[str]) -> str: # ── Build ─────────────────────────────────────────────────────── +def _run_tests() -> None: + """Test with the same interpreter used to build the application.""" + try: + import pytest # noqa: F401 + except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pytest"]) + print("\n[INFO] Führe alle Tests aus ...", flush=True) + result = subprocess.run( + [sys.executable, "-m", "pytest", str(APP_DIR / "tests"), "-v", "-p", "no:cacheprovider"], + cwd=str(PROJECT_ROOT), + ) + if result.returncode != 0: + print("[FEHLER] Tests fehlgeschlagen. Kein Build und kein Deployment.") + raise SystemExit(1) + def _ensure_pyinstaller() -> None: """Stellt sicher, dass PyInstaller installiert ist.""" try: @@ -196,14 +214,20 @@ def _prepare_build_dir() -> None: target = BUILD_OUTPUT_DIR / folder target.mkdir(exist_ok=True) - # Kopiere config-Dateien, falls noch nicht vorhanden + # Liefere die zum aktuellen Quellstand gehörende Konfiguration aus. config_src = APP_DIR / "config" config_dst = BUILD_OUTPUT_DIR / "config" if config_src.exists(): for f in config_src.iterdir(): - if f.is_file() and not (config_dst / f.name).exists(): + if f.is_file(): shutil.copy2(f, config_dst / f.name) + shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md") + shutil.copy2( + APP_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf", + BUILD_OUTPUT_DIR / "template" / "Tätigkeitsnachweise Stellenförderung.pdf", + ) + # README für input-Ordner input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt" if not input_readme.exists(): @@ -308,11 +332,16 @@ def main() -> None: parser = argparse.ArgumentParser( description="Build mit Semantic Versioning", ) - parser.add_argument( + version_options = parser.add_mutually_exclusive_group() + version_options.add_argument( "--bump", choices=["major", "minor", "patch"], help="Version bump-Typ (ohne Angabe: interaktiv)", ) + version_options.add_argument("--no-bump", action="store_true", + help="Aktuelle Version erneut bauen; Changelog unverändert übernehmen") + parser.add_argument("--no-deploy", action="store_true", + help="Nur lokal bauen, ohne anschließendes Deployment") parser.add_argument( "--message", "-m", help="Changelog-Beschreibung für diese Version", @@ -327,6 +356,8 @@ def main() -> None: # ── Version ermitteln ──────────────────────────────────── current = get_current_version() if not current: + if args.no_bump: + parser.error("Für --no-bump muss eine Version im CHANGELOG.md vorhanden sein.") print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.") current = "0.1" @@ -334,7 +365,7 @@ def main() -> None: # Bump-Typ bestimmen bump_type = args.bump - if not bump_type: + if not bump_type and not args.no_bump: print("\nWelche Art von Änderung?") print(" [1] major – inkompatible API-Änderungen") print(" [2] minor – neue Funktionalität (abwärtskompatibel)") @@ -346,32 +377,54 @@ def main() -> None: print("\nAbgebrochen.") sys.exit(0) - new_version = bump_version(current, bump_type) + new_version = current if args.no_bump else bump_version(current, bump_type) print(f"🚀 Neue Version: {new_version}") if args.dry_run: print("\n[Trockenlauf – kein Build]") - print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]") + print(" Würde zuerst alle Tests ausführen") + if not args.no_bump: + print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]") print(f" Würde PyInstaller-Build starten") + if not args.no_deploy: + print(" Würde anschließend ein versioniertes ZIP auf dem SMB-Laufwerk bereitstellen") return - # ── CHANGELOG aktualisieren ────────────────────────────── - print("\n📝 Changelog …") - update_changelog(new_version, bump_type, args.message) - - # ── Build ──────────────────────────────────────────────── - _ensure_pyinstaller() - _prepare_build_dir() + _run_tests() + previous_changelog = _read_changelog() try: + _ensure_pyinstaller() + (BUILD_OUTPUT_DIR / "release.json").unlink(missing_ok=True) + if not args.no_bump: + print("\n📝 Changelog …") + update_changelog(new_version, bump_type, args.message) + _prepare_build_dir() exe_path = _run_pyinstaller() final_exe = _copy_to_build(exe_path) + write_release_manifest(BUILD_OUTPUT_DIR, new_version) + except (Exception, SystemExit, KeyboardInterrupt): + _write_changelog(previous_changelog) + raise finally: _cleanup() _save_version(new_version) _print_summary(new_version, final_exe) + if args.no_deploy: + print("[INFO] Lokaler Build abgeschlossen; Deployment nicht angefordert.") + return + try: + deploy() + except (OSError, ValueError, KeyError) as exc: + print(f"[FEHLER] {exc}") + print("[INFO] Der Build ist fertig. Deployment später mit 1_build-pipeline\\deploy.bat wiederholen.") + raise SystemExit(1) if __name__ == "__main__": - main() + try: + main() + except (OSError, ValueError, subprocess.CalledProcessError) as exc: + print(f"[FEHLER] Pipeline abgebrochen: {exc}") + raise SystemExit(1) diff --git a/1_build-pipeline/deploy.bat b/1_build-pipeline/deploy.bat new file mode 100644 index 0000000..1d75009 --- /dev/null +++ b/1_build-pipeline/deploy.bat @@ -0,0 +1,18 @@ +@echo off +chcp 65001 >nul +setlocal +set "PYTHONUTF8=1" +cd /d "%~dp0.." + +if exist "0_application\.venv\Scripts\python.exe" ( + set "DEPLOY_PYTHON=0_application\.venv\Scripts\python.exe" +) else if exist ".venv\Scripts\python.exe" ( + set "DEPLOY_PYTHON=.venv\Scripts\python.exe" +) else ( + set "DEPLOY_PYTHON=python" +) + +"%DEPLOY_PYTHON%" "1_build-pipeline\deploy.py" +set "DEPLOY_EXIT_CODE=%ERRORLEVEL%" +if /I not "%~1"=="--no-pause" pause +exit /b %DEPLOY_EXIT_CODE% diff --git a/1_build-pipeline/deploy.py b/1_build-pipeline/deploy.py new file mode 100644 index 0000000..561145b --- /dev/null +++ b/1_build-pipeline/deploy.py @@ -0,0 +1,143 @@ +"""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", + "template/Tätigkeitsnachweise Stellenförderung.pdf", +] + + +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 / 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: + 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: + 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())