CI: build pipeline testet nun immer automatisch vor dem build alle Test. Ausserdem gibts einen deploy-pfad
This commit is contained in:
1 parent
971459c9bf
commit
2aad2c9d99
12 files changed
+543
-27
No files matched your search
@@ -1 +1 @@
|
||||
1.3
|
||||
1.3.2
|
||||
@@ -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
|
||||
+68
-15
@@ -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)
|
||||
@@ -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%
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user