Compare commits

..

8 Commits

10 changed files with 167 additions and 98 deletions
+69 -88
View File
@@ -14,7 +14,16 @@ on:
options:
- patch
- minor
- major
- development
push_dev:
description: "If true, push the :dev image to GHCR for development releases"
required: false
default: "false"
type: choice
options:
- "true"
- "false"
permissions:
contents: write
@@ -39,61 +48,46 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME"
else
TAG="$(python3 - <<'PY'
import json
import os
import re
import urllib.request
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
latest_tag="v3.0.0"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [ -n "$tag_name" ]; then
latest_tag="$tag_name"
fi
fi
repo = os.environ["REPO"]
token = os.environ.get("GH_TOKEN", "")
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower()
if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}
else
major=3; minor=0; patch=0
fi
req = urllib.request.Request(
f"https://api.github.com/repos/{repo}/releases/latest",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "legendary-octo-garbanzo-release-workflow",
},
)
# Bump strategy: major / minor / patch
if [ "${BUMP_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
minor=$((minor + 1)); patch=0
else
patch=$((patch + 1))
fi
latest_tag = "v3.0.0"
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8"))
latest_tag = (data.get("tag_name") or latest_tag).strip()
except Exception:
pass
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", latest_tag)
if not m:
major, minor, patch = 3, 0, 0
else:
major, minor, patch = map(int, m.groups())
# Bump strategy: major / minor / patch
if bump == "major":
major += 1
minor = 0
patch = 0
elif bump == "minor":
minor += 1
patch = 0
else:
patch += 1
print(f"v{major}.{minor}.{patch}")
PY
)"
if [ "${BUMP_TYPE:-}" = "development" ]; then
TAG="v${major}.${minor}.${patch}-dev"
else
TAG="v${major}.${minor}.${patch}"
fi
fi
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)"
exit 1
fi
@@ -116,12 +110,15 @@ jobs:
exit 1
fi
while git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; do
BASE="${TAG%.*}"
PATCH="${TAG##*.}"
PATCH="$((PATCH + 1))"
TAG="$BASE.$PATCH"
done
# Ensure tag uniqueness: if tag exists append numeric suffix
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1
base="$TAG"
while git rev-parse -q --verify "refs/tags/${base}.${i}" >/dev/null; do
i="$((i + 1))"
done
TAG="${base}.${i}"
fi
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
@@ -131,9 +128,14 @@ jobs:
else
echo "is_development=false" >> "$GITHUB_OUTPUT"
fi
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
echo "push_dev=true" >> "$GITHUB_OUTPUT"
else
echo "push_dev=false" >> "$GITHUB_OUTPUT"
fi
- name: Create and push tag for manual releases
if: github.event_name == 'workflow_dispatch' && steps.meta.outputs.is_development != 'true'
if: github.event_name == 'workflow_dispatch'
run: |
TAG="${{ steps.meta.outputs.tag }}"
git config user.name "github-actions[bot]"
@@ -162,14 +164,15 @@ jobs:
${{ steps.meta.outputs.image }}
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
- name: Build and push development image
- name: Build and push development image (:dev)
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/aiirondev/legendary-octo-garbanzo:development
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
- name: Build local image tar for offline deploy
run: |
@@ -193,26 +196,7 @@ jobs:
docker build -t "$IMG" .
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
- name: Build local development image tar for offline deploy
run: |
set -euo pipefail
DEV_IMG=ghcr.io/aiirondev/legendary-octo-garbanzo:development
if docker image inspect "$DEV_IMG" >/dev/null 2>&1; then
echo "Using local development image $DEV_IMG"
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
exit 0
fi
echo "Local development image not found, trying to pull $DEV_IMG"
if docker pull "$DEV_IMG" >/dev/null 2>&1; then
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
exit 0
fi
echo "Pull failed, attempting local docker build for development image"
docker build -t "$DEV_IMG" .
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
- name: Create release-only docker bundle
run: |
@@ -297,18 +281,15 @@ jobs:
fi
done
# Include optional development image tar and helper note
if [ -f "inventarsystem-image-development.tar.gz" ]; then
cp inventarsystem-image-development.tar.gz release-bundle/ || true
fi
cat > release-bundle/DEVELOPMENT.md <<EOF
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
This prerelease is intentionally separate from normal releases and will not be used by default.
To install this prerelease on a target host run:
./update.sh ${{ steps.meta.outputs.tag }}
cat > release-bundle/DEVELOPMENT.md <<'EOF'
This bundle contains an optional development image tar: inventarsystem-image-development.tar.gz
To install the development build on a target host, extract the bundle and run:
./update.sh development
EOF
# Make any shipped scripts executable
@@ -316,13 +297,13 @@ jobs:
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
if: steps.meta.outputs.is_development != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
prerelease: ${{ steps.meta.outputs.is_development }}
files: |
inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
inventarsystem-image-development.tar.gz
inventarsystem-image-dev.tar.gz
fail_on_unmatched_files: false
generate_release_notes: true
+38
View File
@@ -0,0 +1,38 @@
Release-Optionen
=================
Diese Datei beschreibt die Eingabeoptionen des CI-Workflows `.github/workflows/release-docker.yml`.
Inputs (workflow_dispatch)
- `bump` (choice)
- `patch` (Standard): Erhöht nur die Patch-Version (vX.Y.Z -> vX.Y.Z+1).
- `minor`: Erhöht die Minor-Version und setzt Patch auf 0 (vX.Y.Z -> vX.Y+1.0).
- `major`: Erhöht die Major-Version und setzt Minor/Patch auf 0 (vX.Y.Z -> vX+1.0.0).
- `development`: Erzeugt einen Development-Release mit Suffix `-dev` (z. B. `v3.1.4-dev`).
- `push_dev` (choice, optional)
- `false` (Standard): Bei `bump=development` wird das `:dev`-Image NICHT automatisch an GHCR gepusht.
- `true`: Bei `bump=development` wird zusätzlich das Image `ghcr.io/aiirondev/legendary-octo-garbanzo:dev` gepusht.
Verhalten/Anmerkungen
- Development releases werden als GitHub Release erzeugt und als `prerelease` markiert, damit sie nicht automatisch von normalen UpdateFlows genutzt werden.
- Es gibt pro Release genau einen ReleaseEintrag (für DevReleases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den ReleaseTag / ImageTag.
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite ReleaseTags wie `v3.1.4-dev`.
Beispiele
- Patch-Release (manuell):
- GitHub UI: Run workflow → `bump=patch`
- CLI mit `gh`:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=patch
- Development prerelease (ohne Push des :dev Images):
- GitHub UI: Run workflow → `bump=development` (leave `push_dev=false`)
- Ergebnis: Release `vX.Y.Z-dev` als prerelease, Image wird nicht automatisch als `:dev` gepusht.
- Development prerelease + push des :dev Images:
- GitHub UI: Run workflow → `bump=development`, `push_dev=true`
- CLI Beispiel:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=development --field push_dev=true
Empfehlung
- Verwende `bump=development` für experimentelle/early releases; Nutzer müssen explizit `./update.sh vX.Y.Z-dev` ausführen, um auf diese Version zu upgraden.
+1
View File
@@ -0,0 +1 @@
# Web package initialization
+53 -6
View File
@@ -28,7 +28,20 @@ Features:
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.exceptions import HTTPException
from werkzeug.routing import BuildError
from jinja2 import TemplateNotFound
import os
import sys
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_CURRENT_DIR)
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
if _CURRENT_DIR not in sys.path:
sys.path.insert(0, _CURRENT_DIR)
import Web.modules.database.user as us
import Web.modules.database.items as it
import Web.modules.database.ausleihung as au
@@ -42,7 +55,6 @@ from urllib.parse import urlparse, urlunparse
import requests
import csv
import ipaddress
import os
import json
import datetime
import time
@@ -63,7 +75,6 @@ except Exception:
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
import threading
import sys
import shutil
import uuid
from PIL import Image, ImageOps
@@ -373,7 +384,7 @@ def _enforce_module_access():
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect('/library')
return redirect(url_for('my_borrowed_items'))
@@ -433,6 +444,41 @@ def handle_not_found(e):
return redirect(url_for('login'))
@app.errorhandler(BuildError)
def handle_build_error(e):
"""Handle url_for endpoint mismatches without crashing the whole request."""
app.logger.error(f"BuildError while resolving endpoint: {e}", exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Route resolution failed', 'status': 500}), 500
# Known fallback for legacy library endpoint naming issues.
if 'library_view' in str(e):
return redirect('/library')
if 'username' in session:
return redirect(url_for('home'))
return redirect(url_for('login'))
@app.errorhandler(Exception)
def handle_unexpected_exception(e):
"""Last-resort handler to avoid raw 500 pages for unhandled exceptions."""
if isinstance(e, HTTPException):
return e
app.logger.error('Unhandled exception', exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Internal server error', 'status': 500}), 500
if 'username' in session:
try:
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
except Exception:
return 'Internal Server Error', 500
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400
@@ -2640,7 +2686,7 @@ def home():
if not cfg.MODULES.is_enabled('inventory'):
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect('/library')
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
@@ -2716,8 +2762,9 @@ def tutorial_page():
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
@app.route('/library/export', defaults={'scope': 'all'})
@app.route('/library/export/<scope>')
def library_export_excel(scope):
def library_export_excel(scope='all'):
if 'username' not in session:
return redirect(url_for('login'))
@@ -2737,7 +2784,7 @@ def library_export_excel(scope):
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
elif scope == 'all_borrowed':
if not is_admin_user:
return redirect(url_for('library_view'))
return redirect('/library')
query['Verfuegbar'] = False
filename = "Bibliothek_Alle_Ausleihen.xlsx"
elif scope == 'schulbuecher':
+1
View File
@@ -0,0 +1 @@
# Web.modules package initialization
+1
View File
@@ -0,0 +1 @@
# Web.modules.bibliothek package initialization
+1 -1
View File
@@ -135,7 +135,7 @@ def _get_int_env(name, default):
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '.docker-build.env'), 'r') as f:
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
+1
View File
@@ -0,0 +1 @@
# Web.modules.emailservice package initialization
+1 -1
View File
@@ -124,7 +124,7 @@
</div>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a>
</div>
</div>
+1 -2
View File
@@ -489,7 +489,7 @@ main() {
# If user requested a development install, perform a simple dev deploy flow
if [ "$MODE" = "development" ]; then
log_message "Requested development install"
local tag="development"
local tag="dev"
local app_image="$APP_IMAGE_REPO:$tag"
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
@@ -523,7 +523,6 @@ EOF
# Bring up stack
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 || true
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
if ! verify_stack_health; then
log_message "ERROR: Development deployment failed health check"