CI: build pipeline testet nun immer automatisch vor dem build alle Test. Ausserdem gibts einen deploy-pfad

This commit is contained in:
Rob committed 2026-10-07 20:44:51 +02:00
1 parent 971459c9bf
commit 2aad2c9d99
12 files changed
+543 -27

No files matched your search

+16
View File
@@ -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
+54 -1
View File
@@ -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.
+19 -5
View File
@@ -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)) {
+1 -1
View File
@@ -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..."
+185
View File
@@ -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()
+32 -2
View File
@@ -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';