From 2d26eb1036ef71d820fd1142d8bd1d9700eb5a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Thu, 1 Oct 2026 11:01:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(template):=20Refresh=20aus=20dem=20Framewo?= =?UTF-8?q?rk-Review=20=E2=80=94=20Pins,=20Prod-Ger=C3=BCst,=20ci=5Fhost,?= =?UTF-8?q?=20skip=5Fif=5Fexists,=20Render-Job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paket-Pins aktuell (integrations 0.2.2, auth 0.1.1, lti 0.1.4, ui 0.6.0 — bisher u. a. integrations 0.1.0 ohne clean_media_url-Fix). - api.ts: same-origin im Prod-Build statt localhost-Fallback; 204 und FormData korrekt; init-Spread überschreibt Header nicht mehr. - App-Shell: PreferencesMenu aus @basicbar/ui statt 90-Zeilen-Eigenbau. - settings: ImproperlyConfigured bei DEBUG=0 mit Dev-SECRET_KEY. - Prod-Gerüst: Root-Dockerfile (SPA gebacken), docker-compose.prod.yml, .env.prod.example, .dockerignore; Dev-Dockerfile ohne libpq-dev/gcc, CMD uvicorn; gunicorn raus. - Neue Frage ci_host: GitHub Actions (CI + GHCR-Release) oder GitLab-CI, bedingt gerendert. - _skip_if_exists für Identitäts-/Inhaltsdateien, _message_after_copy. - ruff-Config (Import-Sortierung), echte Tests in common/tests.py, Kopierreste bereinigt, manage.py ausführbar (EXE001). - Repo-CI: template-probe rendert mit LTI und fährt ruff, check, makemigrations --check, Tests gegen PostgreSQL, Frontend-Build; Paket-Jobs als Matrix mit Caches. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 75 +++++++--- CHANGELOG.md | 26 ++++ copier.yml | 60 ++++++-- template/README.md | 51 +++++-- template/project/.dockerignore | 28 ++++ template/project/CLAUDE.md.jinja | 18 ++- template/project/Dockerfile | 36 +++++ template/project/README.md.jinja | 35 ++++- template/project/backend/.dockerignore | 8 + template/project/backend/Dockerfile | 8 +- .../project/backend/accounts/permissions.py | 2 +- template/project/backend/common/pagination.py | 3 +- template/project/backend/common/tests.py | 23 +++ .../project/backend/config/settings.py.jinja | 9 ++ template/project/backend/manage.py | 0 template/project/backend/pyproject.toml | 12 ++ .../project/backend/requirements.txt.jinja | 9 +- .../project/docker-compose.prod.yml.jinja | 139 ++++++++++++++++++ template/project/frontend/package.json.jinja | 2 +- template/project/frontend/src/App.tsx.jinja | 108 ++------------ template/project/frontend/src/api.ts.jinja | 11 +- .../frontend/src/locales/de/translation.json | 5 +- .../workflows/ci.yml.jinja | 59 ++++++++ .../workflows/release.yml.jinja | 74 ++++++++++ ...gitlab' %}.gitlab-ci.yml{% endif %}.jinja} | 4 +- 25 files changed, 636 insertions(+), 169 deletions(-) create mode 100644 template/project/.dockerignore create mode 100644 template/project/Dockerfile create mode 100644 template/project/backend/.dockerignore mode change 100644 => 100755 template/project/backend/manage.py create mode 100644 template/project/backend/pyproject.toml create mode 100644 template/project/docker-compose.prod.yml.jinja create mode 100644 template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/ci.yml.jinja create mode 100644 template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/release.yml.jinja rename template/project/{.gitlab-ci.yml.jinja => {% if ci_host == 'gitlab' %}.gitlab-ci.yml{% endif %}.jinja} (78%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19cf8ea..c59e90b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,43 +22,80 @@ jobs: fi echo "SPDX-Header vollständig." - integrations-tests: + django-packages: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [integrations, auth, lti] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install --quiet ./packages/django/basicbar-integrations - - run: cd packages/django/basicbar-integrations && python runtests.py + cache: pip + cache-dependency-path: packages/django/basicbar-${{ matrix.package }}/pyproject.toml + - run: pip install --quiet ./packages/django/basicbar-${{ matrix.package }} + - run: cd packages/django/basicbar-${{ matrix.package }} && python runtests.py - auth-tests: + ui-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-node@v4 with: - python-version: "3.12" - - run: pip install --quiet ./packages/django/basicbar-auth - - run: cd packages/django/basicbar-auth && python runtests.py + node-version: "22" + cache: npm + cache-dependency-path: packages/ui/package-lock.json + - run: cd packages/ui && npm ci --no-fund --no-audit + - run: cd packages/ui && npm run build + - run: cd packages/ui && npx tsc --noEmit - lti-tests: + # Renders the Copier template with defaults (LTI on, so every conditional + # branch is exercised) and runs the generated tool's lint, checks, test suite + # and frontend build — catches Jinja errors, stale package pins and broken + # settings before the next real tool does. + template-probe: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: probe + POSTGRES_USER: probe + POSTGRES_PASSWORD: probe + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U probe" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + POSTGRES_DB: probe + POSTGRES_USER: probe + POSTGRES_PASSWORD: probe + POSTGRES_HOST: localhost + POSTGRES_PORT: "5432" steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install --quiet ./packages/django/basicbar-lti - - run: cd packages/django/basicbar-lti && python runtests.py - - ui-build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" - - run: cd packages/ui && npm ci --no-fund --no-audit - - run: cd packages/ui && npm run build - - run: cd packages/ui && npx tsc --noEmit + - name: Render template + run: >- + pipx run copier copy --trust --defaults --vcs-ref HEAD + --data project_slug=probe --data use_lti=true --data ci_host=github + . /tmp/probe + - name: Backend lint, checks, tests + run: | + cd /tmp/probe/backend + pip install --quiet -r requirements.txt ruff + ruff check . + python manage.py check + python manage.py makemigrations --check --dry-run + python manage.py test + - name: Frontend build + run: cd /tmp/probe/frontend && npm install --no-fund --no-audit && npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index e931452..01fdd4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,32 @@ Uhren zwischen IdP und Tool ggf. `OIDC_BACKCHANNEL_MAX_AGE` erhöhen. Zeile in `config/settings.py` übernehmen. - CLAUDE.md: Der ui-Release entsteht als Draft und muss manuell veröffentlicht werden, sonst ist der Tarball nicht abrufbar. +- **Template-Refresh (Framework-Review):** Paket-Pins aktuell (integrations + 0.2.2, auth 0.1.1, lti 0.1.4, ui 0.6.0 — bisher 0.1.0/0.1.0/0.1.2/0.2.1, + d. h. ohne den `clean_media_url`-Sicherheitsfix); `api.ts` fällt im + Prod-Build nicht mehr auf `localhost` zurück (same-origin) und behandelt + 204/FormData korrekt; die 90-Zeilen-`SettingsMenu`-Kopie ist durch + `PreferencesMenu` aus @basicbar/ui ersetzt (Katalog-Key „Preferences“); + Settings verweigern `DEBUG=0` mit dem Dev-`SECRET_KEY`. +- **Prod-Gerüst** (bislang nur in abstimmbar/ausleihbar): Root-`Dockerfile` + (Multi-Stage, SPA gebacken), `docker-compose.prod.yml` (Pflichtvariablen + per `:?`), `.env.prod.example`, Release-Workflow nach GHCR. +- **Neue Copier-Frage `ci_host`** (github/gitlab, Default github): rendert + GitHub-Actions-CI + Release-Workflow oder die GitLab-Pipeline. +- **`_skip_if_exists`**: `copier update` lässt Identitäts-/Inhaltsdateien + (tailwind.config.js, App.tsx, locales, README/CLAUDE.md, Keycloak-Realm, + Caddyfile, .env.prod.example) in Ruhe — erkennbars 11 Hue-Konflikte + entfallen damit. `_message_after_copy` nennt die ersten Schritte. +- Schlankere Images: kein `libpq-dev`/`gcc` mehr (Wheels), `.dockerignore` + für Dev- und Prod-Build-Kontext; `gunicorn` entfernt (ASGI/uvicorn); + ruff-Konfiguration (`backend/pyproject.toml`, Import-Sortierung) und + echte Basis-Tests in `common/tests.py`. +- Repo-CI: `template-probe`-Job rendert das Template (mit LTI) und fährt + Lint, `check`, `makemigrations --check`, Tests gegen PostgreSQL und den + Frontend-Build der generierten Anwendung; Paket-Jobs als Matrix mit Caches. +- Migration Bestandstools (optional, per `copier update --vcs-ref HEAD`): + Konflikte sind in Gerüst-Dateien (compose, Dockerfiles, settings, api.ts) + zu erwarten — Diff lesen; die Identitätsdateien bleiben unberührt. ### @basicbar/ui (→ wird `ui/v0.6.0`) diff --git a/copier.yml b/copier.yml index 9fa612b..1f07834 100644 --- a/copier.yml +++ b/copier.yml @@ -1,11 +1,37 @@ # Copier-Template der virtUOS "-bar"-Tools (Basicbar Phase 6). -# Neues Tool erzeugen: -# pipx run copier copy https://github.com/virtUOS/basicbar.git mein-tool -# Später Template-Updates einspielen: `pipx run copier update` im Projekt. +# Neues Tool erzeugen (--vcs-ref HEAD ist Pflicht, sonst gilt der neueste +# Paket-Tag als Template-Version): +# pipx run copier copy --vcs-ref HEAD https://github.com/virtUOS/basicbar.git mein-tool +# Später Template-Updates einspielen: `pipx run copier update --vcs-ref HEAD`. _subdirectory: template/project _min_copier_version: "9.0.0" +# Dateien, die nach der Generierung dem Tool gehören — Identität (Farb-Ramps), +# App-Shell, Übersetzungskataloge, Doku, Deployment-Spezifika. `copier update` +# lässt sie in Ruhe; Gerüst-Updates betreffen compose, Dockerfiles, CI, +# settings, api-Client. +_skip_if_exists: + - frontend/tailwind.config.js + - frontend/src/App.tsx + - frontend/src/locales/** + - README.md + - CLAUDE.md + - keycloak/realm-export.json + - Caddyfile + - .env.prod.example + +_message_after_copy: | + {{ project_title }} ist generiert. Nächste Schritte: + + cd {{ _copier_conf.dst_path }} + git init -b main && git add -A && git commit -m "Gerüst aus basicbar-Template" + docker compose up -d && docker compose exec backend python manage.py migrate + docker compose exec frontend npm install --package-lock-only # Lockfile committen (CI-Cache) + + Login: http://localhost:{{ frontend_port }} mit demo/demo (Admin: admin-demo/demo). + Nach dem ersten Push: main schützen (Push "no one", Merge via PR/MR). + project_slug: type: str help: "Technischer Name (klein, ohne Sonderzeichen) — Container, DB, Realm, Client-IDs" @@ -24,28 +50,28 @@ project_description: frontend_port: type: int - help: "Host-Port des Vite-Dev-Servers (ausleihbar 5173, abstimmbar 5174, modulierbar 5175, …)" - default: 5176 + help: "Host-Port des Vite-Dev-Servers (ausleihbar 5173, abstimmbar 5174, modulierbar 5175, erkennbar 5176, …)" + default: 5177 backend_port: type: int - help: "Host-Port des Django-Backends (ausleihbar 8001, abstimmbar 8002, modulierbar 8003, …)" - default: 8004 + help: "Host-Port des Django-Backends (ausleihbar 8001, abstimmbar 8002, modulierbar 8003, erkennbar 8004, …)" + default: 8005 keycloak_port: type: int - help: "Host-Port des Dev-Keycloak (ausleihbar 8080, abstimmbar 8081, modulierbar 8082, …)" - default: 8083 + help: "Host-Port des Dev-Keycloak (ausleihbar 8080, abstimmbar 8081, modulierbar 8082, erkennbar 8083, …)" + default: 8084 postgres_port: type: int - help: "Host-Port von PostgreSQL (ausleihbar 5432, abstimmbar 5433, modulierbar 5434, …)" - default: 5435 + help: "Host-Port von PostgreSQL (ausleihbar 5432, abstimmbar 5433, modulierbar 5434, erkennbar 5435, …)" + default: 5436 brand_hue: type: int - help: "OKLCH-Farbton des Marken-Akzents (ausleihbar Honig ≈ 91, abstimmbar Grün ≈ 150; Startwert, Feintuning in tailwind.config.js)" - default: 260 + help: "OKLCH-Farbton des Marken-Akzents (ausleihbar Honig ≈ 91, abstimmbar Grün ≈ 150, erkennbar Violett ≈ 260; Startwert, Feintuning in tailwind.config.js)" + default: 320 neutral_hue: type: int @@ -56,3 +82,11 @@ use_lti: type: bool help: "LTI 1.3 vorbereiten (basicbar-lti: Plattform-Registrierung, Middleware — Launch-App folgt nach abstimmbar-Vorbild)?" default: false + +ci_host: + type: str + help: "Wo laufen CI und Release? github = GitHub Actions + Release-Image nach GHCR (ADR-0004), gitlab = Pipeline auf der Uni-GitLab" + choices: + - github + - gitlab + default: github diff --git a/template/README.md b/template/README.md index 502b890..c545671 100644 --- a/template/README.md +++ b/template/README.md @@ -11,17 +11,40 @@ cd mein-tool && git init -b main && git add -A && git commit -m "Gerüst aus bas docker compose up -d && docker compose exec backend python manage.py migrate ``` -Danach empfohlen: Frontend-Lockfile erzeugen und committen (der CI-Cache -hängt daran: `docker compose exec frontend npm install --package-lock-only`), -nach dem ersten Push den main-Branch schützen (Push „no one“, Merge via MR) -und Gerüst-Updates später mit `pipx run copier update --vcs-ref HEAD` holen. - -Enthalten: Django + DRF auf `basicbar-auth`/`basicbar-integrations` -(LTI 1.3 optional per Frage), React/Vite auf `@basicbar/ui` (App-Shell mit -Login/Theme/Sprache; Farb-Ramps aus den Hue-Fragen als Start-Identität), -Keycloak-Dev-Realm mit Demo-Konten, Compose-Stack mit projektfreien Ports, -Caddyfile, CI (ruff, Tests gegen PostgreSQL, Frontend-Build), README/CLAUDE.md. - -Spätere Gerüst-Änderungen holt ein Tool mit `pipx run copier update` -(Basis: die generierte `.copier-answers.yml`). Erster echter Konsument: -**erkennbar** (2026-07-19, inkl. verifiziertem copier-update-Roundtrip). +Die Fragen: Name/Titel/Beschreibung, vier projektfreie Ports, zwei +OKLCH-Farbtöne als Start-Identität, `use_lti` und `ci_host` (`github` = +GitHub Actions + Release-Image nach GHCR, `gitlab` = Pipeline auf der +Uni-GitLab). Copier zeigt nach der Generierung die nächsten Schritte an +(Lockfile committen, main schützen). + +Enthalten: Django + DRF auf `basicbar-auth`/`basicbar-integrations` (LTI 1.3 +optional), React/Vite auf `@basicbar/ui` (App-Shell mit Login und +`PreferencesMenu`; Farb-Ramps aus den Hue-Fragen), Keycloak-Dev-Realm mit +Demo-Konten, Compose-Stack, ruff-Konfiguration, Basis-Tests, CI — **und das +Prod-Gerüst**: Root-`Dockerfile` (SPA gebacken), `docker-compose.prod.yml` +(Pflichtvariablen per `:?`), `.env.prod.example`, `Caddyfile`, bei GitHub der +Release-Workflow nach GHCR. Die Settings verweigern `DJANGO_DEBUG=0` mit dem +Entwicklungs-`SECRET_KEY`. + +## Updates in bestehende Tools + +`pipx run copier update --vcs-ref HEAD` im Tool spielt Gerüst-Änderungen als +Diff ein (Basis: `.copier-answers.yml`). Per `_skip_if_exists` bleiben die +Dateien unberührt, die dem Tool gehören: `tailwind.config.js` (Identität), +`App.tsx`, `locales/`, `README.md`, `CLAUDE.md`, `keycloak/realm-export.json`, +`Caddyfile`, `.env.prod.example`. Updates betreffen also compose, Dockerfiles, +CI, `settings.py`, `api.ts` — dort den Diff lesen. + +## Qualitätssicherung + +Die Repo-CI (`template-probe`) rendert das Template bei jedem Push mit +Defaults (LTI an, `ci_host=github`) und fährt Lint, `manage.py check`, +`makemigrations --check`, die Testsuite gegen PostgreSQL und den +Frontend-Build der generierten Anwendung. Lokal: + +```bash +pipx run copier copy --trust --defaults --vcs-ref HEAD --data project_slug=probe . /tmp/probe +``` + +Erster echter Konsument: **erkennbar** (2026-07-19, inkl. verifiziertem +copier-update-Roundtrip). diff --git a/template/project/.dockerignore b/template/project/.dockerignore new file mode 100644 index 0000000..2fa23c1 --- /dev/null +++ b/template/project/.dockerignore @@ -0,0 +1,28 @@ +.git/ + +# Python +**/__pycache__/ +**/*.py[cod] +.venv/ +venv/ +**/*.egg-info/ + +# Node +**/node_modules/ +frontend/dist/ +**/*.tsbuildinfo + +# Environment / secrets +.env +.env.* +!.env.example +!.env.prod.example + +# Editors & OS +.idea/ +.vscode/ +.DS_Store + +# Local data +media/ +**/*.sqlite3 diff --git a/template/project/CLAUDE.md.jinja b/template/project/CLAUDE.md.jinja index fbf3ddd..d68637a 100644 --- a/template/project/CLAUDE.md.jinja +++ b/template/project/CLAUDE.md.jinja @@ -3,19 +3,27 @@ {{ project_description }} Django-Backend (`backend/`, Apps `config`/`accounts`/ `common`) + React/Vite-SPA (`frontend/`). Gemeinsame Basis der virtUOS "-bar"-Tools aus dem basicbar-Repo: `basicbar-auth` (OIDC), `basicbar-integrations` -(LibreTranslate/LiteLLM/Capabilities){% if use_lti %}, `basicbar-lti`{% endif %} und `@basicbar/ui` -(Tailwind-Preset, base.css, Theme, i18n, TranslatableField). Paketcode nie -lokal patchen — Änderungen gehören ins basicbar-Repo, Upgrade per Versions-Bump -(Changelog dort = Migrationsanleitung). +(LibreTranslate/LiteLLM){% if use_lti %}, `basicbar-lti`{% endif %} und `@basicbar/ui` +(Tailwind-Preset, base.css, Theme, i18n, TranslatableField, Preferences). Paketcode +nie lokal patchen — Änderungen gehören ins basicbar-Repo, Upgrade per +Versions-Bump (Changelog dort = Migrationsanleitung). ## Befehle ```bash docker compose exec backend python manage.py test # Backend-Tests docker compose exec frontend npx tsc -b # Frontend-Typecheck -docker compose exec backend sh -c "pip install -q ruff && ruff check ." # Lint (wie CI) +docker compose exec backend sh -c "pip install -q ruff && ruff check ." # Lint (wie CI, Config in backend/pyproject.toml) ``` +## Workflow + +- Branch + PR/MR, nie direkt auf `main`; der Mensch merged. +- Release = Git-Tag `vX.Y.Z`{% if ci_host == 'github' %} → GitHub Actions baut das Image nach GHCR{% endif %}; + Deployment über `docker-compose.prod.yml` (siehe README). +- Gerüst-Updates: `pipx run copier update --vcs-ref HEAD` (Identitäts- und + Inhaltsdateien sind davon ausgenommen, siehe README). + ## Konventionen - Apache-2.0, SPDX-Header in jeder Quelldatei. diff --git a/template/project/Dockerfile b/template/project/Dockerfile new file mode 100644 index 0000000..1c13cb3 --- /dev/null +++ b/template/project/Dockerfile @@ -0,0 +1,36 @@ +# Production image: builds the SPA and bakes it into the Django image, so one +# versioned artifact per release. On start (see docker-compose.prod.yml) the +# app copies /app/frontend_dist into a volume that Caddy serves directly. + +FROM node:22-slim AS frontend-build +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY frontend/ . +# Same-origin API in production: VITE_API_BASE_URL stays unset (api.ts → ""). +RUN npm run build + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# gettext for compilemessages (add `RUN python manage.py compilemessages` once +# the tool ships a locale/ directory). No compiler needed — wheels only. +RUN apt-get update \ + && apt-get install -y --no-install-recommends gettext \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . +COPY --from=frontend-build /frontend/dist /app/frontend_dist + +EXPOSE 8000 + +# --proxy-headers: trust Caddy's X-Forwarded-* so https is detected. +CMD ["uvicorn", "config.asgi:application", "--host", "0.0.0.0", "--port", "8000", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/template/project/README.md.jinja b/template/project/README.md.jinja index 14046fe..2bcc8cd 100644 --- a/template/project/README.md.jinja +++ b/template/project/README.md.jinja @@ -20,6 +20,30 @@ docker compose exec backend python manage.py test - Django-Admin: http://localhost:{{ backend_port }}/admin/ (Break-glass-Konto per `docker compose exec backend python manage.py createsuperuser`). +## Produktion + +Ein Release ist **ein Image** (Root-`Dockerfile`: SPA gebaut und ins +Django-Image gebacken). `docker-compose.prod.yml` fährt PostgreSQL, die App +(uvicorn, migriert und sammelt Statics beim Start) und Caddy (TLS, serviert +SPA/Statics/Media direkt aus Volumes): + +```bash +cp .env.prod.example .env # ausfüllen — Pflichtvariablen erzwingt compose +# Caddyfile: Domain und ACME-Mail eintragen +docker compose -f docker-compose.prod.yml up -d --build +``` +{% if ci_host == 'github' %} +Ein Tag `vX.Y.Z` baut per GitHub Actions das Image nach +`ghcr.io/virtuos/{{ project_slug }}` (Major-Bumps warten auf Freigabe im +Environment `major-release`); auf dem Server dann `pull` statt `--build` +und bei Bedarf `{{ project_slug | upper }}_VERSION` pinnen. +{% else %} +Die Pipeline auf der Uni-GitLab lintet, testet und baut; das Release-Image +wird auf dem Server per `up -d --build` aus dem Checkout gebaut. +{% endif %} +Die Settings verweigern den Start mit dem Entwicklungs-`SECRET_KEY`, sobald +`DJANGO_DEBUG=0` ist. + ## Basis aktualisieren Die gemeinsamen Bausteine kommen aus dem basicbar-Repo (Changelog dort ist @@ -27,11 +51,16 @@ die Upgrade-Anleitung): - **Backend-Pakete** (`backend/requirements.txt`): Tag in der URL heben. - **@basicbar/ui** (`frontend/package.json`): Tarball-Version heben. -- **Projektgerüst**: `pipx run copier update` spielt Template-Änderungen ein. +- **Projektgerüst**: `pipx run copier update --vcs-ref HEAD` spielt + Template-Änderungen ein. Identitäts- und Inhaltsdateien (Farb-Ramps, + `App.tsx`, Kataloge, README/CLAUDE.md, Keycloak-Realm, Caddyfile, + `.env.prod.example`) überschreibt es nicht — die gehören diesem Tool. ## Konventionen Apache-2.0 mit SPDX-Headern; jede App hält Tests neben dem Code (`docker compose exec backend python manage.py test`); Frontend-Typecheck -mit `docker compose exec frontend npx tsc -b`. CI: Lint (ruff), Backend-Tests -gegen PostgreSQL, Frontend-Build. +mit `docker compose exec frontend npx tsc -b`; Lint mit ruff +(`backend/pyproject.toml`, inkl. Import-Sortierung). CI +({% if ci_host == 'github' %}GitHub Actions{% else %}Uni-GitLab{% endif %}): +Lint, Backend-Tests gegen PostgreSQL, Frontend-Build. diff --git a/template/project/backend/.dockerignore b/template/project/backend/.dockerignore new file mode 100644 index 0000000..08db115 --- /dev/null +++ b/template/project/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +media/ +*.sqlite3 +staticfiles/ diff --git a/template/project/backend/Dockerfile b/template/project/backend/Dockerfile index bd2ef5b..bc2ecbc 100644 --- a/template/project/backend/Dockerfile +++ b/template/project/backend/Dockerfile @@ -5,9 +5,10 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app -# System dependencies: psycopg build deps + gettext (for compilemessages) +# gettext for compilemessages. psycopg[binary] and uvicorn[standard] ship +# wheels, so no compiler or libpq headers are needed. RUN apt-get update \ - && apt-get install -y --no-install-recommends libpq-dev gcc gettext \ + && apt-get install -y --no-install-recommends gettext \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . @@ -17,4 +18,5 @@ COPY . . EXPOSE 8000 -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] +# docker-compose.yml overrides this with --reload for development. +CMD ["uvicorn", "config.asgi:application", "--host", "0.0.0.0", "--port", "8000"] diff --git a/template/project/backend/accounts/permissions.py b/template/project/backend/accounts/permissions.py index 6b361a8..dffa778 100644 --- a/template/project/backend/accounts/permissions.py +++ b/template/project/backend/accounts/permissions.py @@ -7,7 +7,7 @@ class IsAdmin(BasePermission): """Site administrators only — Django staff or superusers. Staff is - granted via the OIDC admin group (see accounts.oidc).""" + granted via the OIDC admin group (basicbar_auth.oidc).""" def has_permission(self, request, view): user = request.user diff --git a/template/project/backend/common/pagination.py b/template/project/backend/common/pagination.py index 2f01525..4f11d79 100644 --- a/template/project/backend/common/pagination.py +++ b/template/project/backend/common/pagination.py @@ -3,8 +3,7 @@ """Default pagination that lets the client pick the page size. -The management/overview lists page and sort client-side (favorites float to -the top, per-user sort), so the SPA fetches the full set with a large +Lists that page and sort client-side fetch the full set with a large ``page_size``; the shape stays ``{count, results}`` for every caller.""" from rest_framework.pagination import PageNumberPagination diff --git a/template/project/backend/common/tests.py b/template/project/backend/common/tests.py index 6993662..7825398 100644 --- a/template/project/backend/common/tests.py +++ b/template/project/backend/common/tests.py @@ -2,3 +2,26 @@ # Copyright 2026 Universität Osnabrück (virtUOS) """Every app keeps at least basic tests next to its code (Repo-Konvention).""" +from django.test import SimpleTestCase +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from .pagination import StandardPagination + + +class StandardPaginationTests(SimpleTestCase): + def _page_size(self, query): + request = Request(APIRequestFactory().get("/", query)) + return StandardPagination().get_page_size(request) + + def test_default_page_size(self): + self.assertEqual(self._page_size({}), 25) + + def test_client_may_pick_page_size(self): + self.assertEqual(self._page_size({"page_size": "5"}), 5) + + def test_page_size_is_capped(self): + self.assertEqual(self._page_size({"page_size": "5000"}), 1000) + + def test_invalid_page_size_falls_back_to_default(self): + self.assertEqual(self._page_size({"page_size": "many"}), 25) diff --git a/template/project/backend/config/settings.py.jinja b/template/project/backend/config/settings.py.jinja index a688229..f8f0fdc 100644 --- a/template/project/backend/config/settings.py.jinja +++ b/template/project/backend/config/settings.py.jinja @@ -5,6 +5,8 @@ import os from pathlib import Path +from django.core.exceptions import ImproperlyConfigured + BASE_DIR = Path(__file__).resolve().parent.parent @@ -31,6 +33,13 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-insecure-secret-key-change DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" +# Refuse to start a production instance on the development key — a forgotten +# .env variable would otherwise sign sessions and CSRF tokens with a public value. +if not DEBUG and SECRET_KEY.startswith("dev-insecure"): + raise ImproperlyConfigured( + "DJANGO_SECRET_KEY must be set to a long random value when DJANGO_DEBUG=0." + ) + ALLOWED_HOSTS = _env_list( "DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0,backend" ) diff --git a/template/project/backend/manage.py b/template/project/backend/manage.py old mode 100644 new mode 100755 diff --git a/template/project/backend/pyproject.toml b/template/project/backend/pyproject.toml new file mode 100644 index 0000000..23201c5 --- /dev/null +++ b/template/project/backend/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" +# Generated code is not held to import-order rules. +extend-exclude = ["*/migrations/*"] + +[tool.ruff.lint] +# Defaults (pyflakes/pycodestyle) plus import sorting. +extend-select = ["I"] + +[tool.ruff.lint.isort] +known-first-party = ["accounts", "common", "config"] diff --git a/template/project/backend/requirements.txt.jinja b/template/project/backend/requirements.txt.jinja index 8e8af76..7b9c5db 100644 --- a/template/project/backend/requirements.txt.jinja +++ b/template/project/backend/requirements.txt.jinja @@ -1,17 +1,16 @@ Django==5.1.4 djangorestframework==3.15.2 # Gemeinsame Basis der -bar-Tools; Versionswechsel = Tag in der URL heben, -# Migrationsschritte im CHANGELOG des basicbar-Repos. -basicbar-integrations @ https://github.com/virtUOS/basicbar/archive/refs/tags/integrations/v0.1.0.tar.gz#subdirectory=packages/django/basicbar-integrations -basicbar-auth @ https://github.com/virtUOS/basicbar/archive/refs/tags/auth/v0.1.0.tar.gz#subdirectory=packages/django/basicbar-auth +# Migrationsschritte im CHANGELOG von https://github.com/virtUOS/basicbar. +basicbar-integrations @ https://github.com/virtUOS/basicbar/archive/refs/tags/integrations/v0.2.2.tar.gz#subdirectory=packages/django/basicbar-integrations +basicbar-auth @ https://github.com/virtUOS/basicbar/archive/refs/tags/auth/v0.1.1.tar.gz#subdirectory=packages/django/basicbar-auth {% if use_lti -%} -basicbar-lti @ https://github.com/virtUOS/basicbar/archive/refs/tags/lti/v0.1.2.tar.gz#subdirectory=packages/django/basicbar-lti +basicbar-lti @ https://github.com/virtUOS/basicbar/archive/refs/tags/lti/v0.1.4.tar.gz#subdirectory=packages/django/basicbar-lti {% endif -%} django-cors-headers==4.6.0 psycopg[binary,pool]==3.2.3 mozilla-django-oidc==4.0.1 # Per-language columns for authored content fields (basicbar-Konvention). django-modeltranslation==0.19.11 -gunicorn==23.0.0 # ASGI server (dev + prod). uvicorn[standard]==0.34.0 diff --git a/template/project/docker-compose.prod.yml.jinja b/template/project/docker-compose.prod.yml.jinja new file mode 100644 index 0000000..d460782 --- /dev/null +++ b/template/project/docker-compose.prod.yml.jinja @@ -0,0 +1,139 @@ +# Production stack: PostgreSQL + Django (uvicorn/ASGI) + Caddy (TLS proxy). +# The app image (root Dockerfile) bakes in the built SPA; on start it copies +# the build into the frontend_data volume, which Caddy serves directly. OIDC +# points at the institutional IdP, so there is no Keycloak service here. +# +# docker compose -f docker-compose.prod.yml up -d --build # build from source +# docker compose -f docker-compose.prod.yml pull # or run a released image, then +# docker compose -f docker-compose.prod.yml up -d +# +# Required env (in .env): PUBLIC_BASE_URL, DJANGO_SECRET_KEY, POSTGRES_PASSWORD, +# DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS and the OIDC_* vars — see +# .env.prod.example. Variables marked `:?` make compose refuse to start when +# they are missing, so a forgotten secret never silently falls back. + +services: + db: + image: postgres:16-alpine + container_name: {{ project_slug }}_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-{{ project_slug }}} + POSTGRES_USER: ${POSTGRES_USER:-{{ project_slug }}} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a strong POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-{{ project_slug }}} -d ${POSTGRES_DB:-{{ project_slug }}}"] + interval: 5s + timeout: 5s + retries: 5 + + # Django/uvicorn plus the baked-in SPA build (see Dockerfile). `image:` lets + # `pull` fetch a released tag directly; `build:` still works via + # `up -d --build`. Override {{ project_slug | upper }}_VERSION to pin/roll back. + app: + image: ghcr.io/virtuos/{{ project_slug }}:${% raw %}{{% endraw %}{{ project_slug | upper }}_VERSION:-latest} + build: + context: . + dockerfile: Dockerfile + container_name: {{ project_slug }}_app + restart: unless-stopped + # Keep every continuation line at this indentation — YAML's folded (">") + # scalar only joins same-indented lines with spaces. + command: > + sh -c "mkdir -p /shared_frontend && + cp -a /app/frontend_dist/. /shared_frontend/ && + python manage.py migrate --noinput && + python manage.py collectstatic --noinput && + uvicorn config.asgi:application --host 0.0.0.0 --port 8000 + --proxy-headers --forwarded-allow-ips '*'" + environment: + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?set DJANGO_SECRET_KEY} + DJANGO_DEBUG: "0" + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:?set DJANGO_ALLOWED_HOSTS} + CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:?set CSRF_TRUSTED_ORIGINS} + # SPA and API share one origin in production — no CORS needed. + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + POSTGRES_DB: ${POSTGRES_DB:-{{ project_slug }}} + POSTGRES_USER: ${POSTGRES_USER:-{{ project_slug }}} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a strong POSTGRES_PASSWORD} + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + # Public URL of the instance (absolute links, OIDC redirects). + FRONTEND_BASE_URL: ${PUBLIC_BASE_URL:?set PUBLIC_BASE_URL} + OIDC_LOGIN_REDIRECT_URL: ${PUBLIC_BASE_URL:?set PUBLIC_BASE_URL}/ + OIDC_LOGOUT_REDIRECT_URL: ${PUBLIC_BASE_URL:?set PUBLIC_BASE_URL}/ + OIDC_RP_CLIENT_ID: ${OIDC_RP_CLIENT_ID:?set OIDC_RP_CLIENT_ID} + OIDC_RP_CLIENT_SECRET: ${OIDC_RP_CLIENT_SECRET:?set OIDC_RP_CLIENT_SECRET} + # Either set the issuer (endpoints via OIDC discovery) … + OIDC_OP_ISSUER: ${OIDC_OP_ISSUER:-} + # … or the endpoints explicitly (explicit values win). + OIDC_OP_AUTHORIZATION_ENDPOINT: ${OIDC_OP_AUTHORIZATION_ENDPOINT:-} + OIDC_OP_TOKEN_ENDPOINT: ${OIDC_OP_TOKEN_ENDPOINT:-} + OIDC_OP_USER_ENDPOINT: ${OIDC_OP_USER_ENDPOINT:-} + OIDC_OP_JWKS_ENDPOINT: ${OIDC_OP_JWKS_ENDPOINT:-} + OIDC_OP_LOGOUT_ENDPOINT: ${OIDC_OP_LOGOUT_ENDPOINT:-} + OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups} + OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-} + # Claim names — override only if the IdP uses non-standard ones. + OIDC_CLAIM_USERNAME: ${OIDC_CLAIM_USERNAME:-preferred_username} + OIDC_CLAIM_EMAIL: ${OIDC_CLAIM_EMAIL:-email} + OIDC_CLAIM_FIRST_NAME: ${OIDC_CLAIM_FIRST_NAME:-given_name} + OIDC_CLAIM_LAST_NAME: ${OIDC_CLAIM_LAST_NAME:-family_name} + # Heal re-issued IdP subjects by username — only where the IdP never + # re-assigns usernames (see basicbar-auth's operator notes). Default off. + OIDC_MATCH_BY_USERNAME_FALLBACK: ${OIDC_MATCH_BY_USERNAME_FALLBACK:-0} +{% if use_lti %} # "None" only when the tool must run inside LMS iframes; the recommended + # new-window launch works with the default "Lax". + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-Lax} +{% endif %} # Optional AI features (LiteLLM). Off unless all four are set. + AI_PROVIDER: ${AI_PROVIDER:-none} + AI_BASE_URL: ${AI_BASE_URL:-} + AI_API_KEY: ${AI_API_KEY:-} + AI_MODEL: ${AI_MODEL:-} + AI_TIMEOUT: ${AI_TIMEOUT:-30} + AI_MAX_TOKENS: ${AI_MAX_TOKENS:-2000} + AI_DISABLE_THINKING: ${AI_DISABLE_THINKING:-1} + # Canonical language of authored content; optional LibreTranslate pre-fill. + CONTENT_DEFAULT_LANGUAGE: ${CONTENT_DEFAULT_LANGUAGE:-de} + CONTENT_TRANSLATION_PROVIDER: ${CONTENT_TRANSLATION_PROVIDER:-none} + LIBRETRANSLATE_URL: ${LIBRETRANSLATE_URL:-} + LIBRETRANSLATE_API_KEY: ${LIBRETRANSLATE_API_KEY:-} + volumes: + - static_data:/app/staticfiles + - media_data:/app/media + - frontend_data:/shared_frontend + depends_on: + db: + condition: service_healthy + expose: + - "8000" + + caddy: + image: caddy:2 + container_name: {{ project_slug }}_caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Optional: mount institutional TLS certificates when not using Caddy's + # automatic Let's Encrypt; see the note in the Caddyfile. + # - /etc/{{ project_slug }}/certs:/etc/caddy/certs:ro + - frontend_data:/srv/www:ro + - static_data:/srv/static:ro + - media_data:/srv/media:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - app + +volumes: + postgres_data: + static_data: + media_data: + frontend_data: + caddy_data: + caddy_config: diff --git a/template/project/frontend/package.json.jinja b/template/project/frontend/package.json.jinja index da5b574..9c85013 100644 --- a/template/project/frontend/package.json.jinja +++ b/template/project/frontend/package.json.jinja @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@basicbar/ui": "https://github.com/virtUOS/basicbar/releases/download/ui/v0.2.1/basicbar-ui-0.2.1.tgz", + "@basicbar/ui": "https://github.com/virtUOS/basicbar/releases/download/ui/v0.6.0/basicbar-ui-0.6.0.tgz", "@fontsource-variable/plus-jakarta-sans": "^5.2.8", "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", diff --git a/template/project/frontend/src/App.tsx.jinja b/template/project/frontend/src/App.tsx.jinja index 30c83e9..ce1de74 100644 --- a/template/project/frontend/src/App.tsx.jinja +++ b/template/project/frontend/src/App.tsx.jinja @@ -1,113 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Universität Osnabrück (virtUOS) -/** App shell: header (login/logout, theme, language), landing page. +/** App shell: header (preferences, login/logout), landing page. * Deliberately small — the tool grows from here. */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useOutletContext } from "react-router-dom"; -import { Check, Globe, LogIn, LogOut, Monitor, Moon, Sun } from "lucide-react"; +import { LogIn, LogOut } from "lucide-react"; import { - SUPPORTED_LANGUAGES, + PreferencesMenu, setDefaultContentLang, setTranslationEnabled, - useTheme, - type Appearance, } from "@basicbar/ui"; import i18n from "./i18n"; import { api, loginUrl, logoutUrl, type Whoami } from "./api"; -function MenuButton({ - active, - onClick, - children, -}: { - active?: boolean; - onClick: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -/** Appearance (Auto/Light/Dark) + UI language, in one small menu. */ -function SettingsMenu({ whoami }: { whoami: Whoami | null }) { - const { t } = useTranslation(); - const { appearance, setAppearance } = useTheme(); - const [open, setOpen] = useState(false); - - const appearances: { value: Appearance; label: string; icon: typeof Sun }[] = [ - { value: "auto", label: t("Auto"), icon: Monitor }, - { value: "light", label: t("Light"), icon: Sun }, - { value: "dark", label: t("Dark"), icon: Moon }, - ]; - - function chooseLanguage(code: string) { - void i18n.changeLanguage(code); - if (whoami?.authenticated) void api.setLanguage(code).catch(() => {}); - } - - return ( -
- - {open && ( -
-

- {t("Appearance")} -

- {appearances.map((option) => ( - setAppearance(option.value)} - > - - - {option.label} - - - ))} -
-

- {t("Language")} -

- {SUPPORTED_LANGUAGES.map((lang) => ( - chooseLanguage(lang.code)} - > - {lang.label} - - ))} -
- )} -
- ); -} - export function Home() { const { t } = useTranslation(); const whoami = useOutletContext(); @@ -163,7 +70,14 @@ export default function App() { {{ "{" }}{{ project_title | tojson }}{{ "}" }}
- + {/* Language + appearance from @basicbar/ui. Once the tool has an + account menu for signed-in users, move LanguageOptions and + AppearanceControl in there (see the @basicbar/ui README). */} + { + if (whoami?.authenticated) void api.setLanguage(lang).catch(() => {}); + }} + /> {whoami?.authenticated ? ( (path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE}${path}`, { credentials: "include", + ...init, headers: { - "Content-Type": "application/json", + // FormData bodies get their multipart boundary from the browser. + ...(init.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...(csrfToken ? { "X-CSRFToken": csrfToken } : {}), ...init.headers, }, - ...init, }); if (!response.ok) { let detail = `HTTP ${response.status}`; @@ -46,6 +50,7 @@ async function request(path: string, init: RequestInit = {}): Promise { } throw new Error(detail); } + if (response.status === 204) return undefined as T; return response.json() as Promise; } diff --git a/template/project/frontend/src/locales/de/translation.json b/template/project/frontend/src/locales/de/translation.json index b83255a..762054c 100644 --- a/template/project/frontend/src/locales/de/translation.json +++ b/template/project/frontend/src/locales/de/translation.json @@ -4,9 +4,10 @@ "Dark": "Dunkel", "Appearance": "Erscheinungsbild", "Language": "Sprache", - "Settings": "Einstellungen", "Sign in": "Anmelden", "Sign out": "Abmelden", "Signed in as": "Angemeldet als", - "Skip to content": "Zum Inhalt springen" + "Skip to content": "Zum Inhalt springen", + "Preferences": "Einstellungen", + "(follows your system)": "(folgt deinem System)" } diff --git a/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/ci.yml.jinja b/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/ci.yml.jinja new file mode 100644 index 0000000..9df09f7 --- /dev/null +++ b/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/ci.yml.jinja @@ -0,0 +1,59 @@ +{% raw %}name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install --quiet ruff + - run: ruff check backend + + backend-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: {% endraw %}{{ project_slug }}{% raw %} + POSTGRES_USER: {% endraw %}{{ project_slug }}{% raw %} + POSTGRES_PASSWORD: {% endraw %}{{ project_slug }}{% raw %} + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U {% endraw %}{{ project_slug }}{% raw %}" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + POSTGRES_DB: {% endraw %}{{ project_slug }}{% raw %} + POSTGRES_USER: {% endraw %}{{ project_slug }}{% raw %} + POSTGRES_PASSWORD: {% endraw %}{{ project_slug }}{% raw %} + POSTGRES_HOST: localhost + POSTGRES_PORT: "5432" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install --quiet -r backend/requirements.txt + - run: cd backend && python manage.py test + + frontend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + # npm ci once the lockfile is committed (reproducible, installs the + # platform-specific optional deps reliably); plain install before that. + - run: cd frontend && if [ -f package-lock.json ]; then npm ci --no-fund --no-audit; else npm install --no-fund --no-audit; fi + - run: cd frontend && npm run build +{% endraw %} \ No newline at end of file diff --git a/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/release.yml.jinja b/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/release.yml.jinja new file mode 100644 index 0000000..d155131 --- /dev/null +++ b/template/project/{% if ci_host == 'github' %}.github{% endif %}/workflows/release.yml.jinja @@ -0,0 +1,74 @@ +{% raw %}name: Release image + +# Publishes the all-in-one image (SPA baked in, see ../../Dockerfile) to GHCR +# whenever a version tag is pushed. Major bumps (v1.x → v2.0.0) run under the +# "major-release" GitHub Environment — configure a required reviewer in +# Settings → Environments and the job pauses for approval, since a major tag +# may ship a breaking change under :latest. Minor/patch tags run under +# "auto-release" and proceed immediately. + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + classify: + name: Classify bump vs. previous tag + runs-on: ubuntu-latest + outputs: + major: ${{ steps.classify.outputs.major }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full tag history to find the previous tag + - name: Classify bump + id: classify + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + ver="${TAG#v}"; cur_core="${ver%%-*}"; cur_major="${cur_core%%.*}" + prev="$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true)" + if [ -z "$prev" ]; then + echo "No previous tag — treating $TAG as a minor/patch release." + echo "major=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + prev_core="${prev#v}"; prev_core="${prev_core%%-*}"; prev_major="${prev_core%%.*}" + if [ "$cur_major" != "$prev_major" ]; then + echo "Major bump $prev → $TAG — build-and-push requires approval." + echo "major=true" >> "$GITHUB_OUTPUT" + else + echo "major=false" >> "$GITHUB_OUTPUT" + fi + + build-and-push: + needs: classify + runs-on: ubuntu-latest + environment: ${{ needs.classify.outputs.major == 'true' && 'major-release' || 'auto-release' }} + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/virtuos/{% endraw %}{{ project_slug }}{% raw %} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} +{% endraw %} \ No newline at end of file diff --git a/template/project/.gitlab-ci.yml.jinja b/template/project/{% if ci_host == 'gitlab' %}.gitlab-ci.yml{% endif %}.jinja similarity index 78% rename from template/project/.gitlab-ci.yml.jinja rename to template/project/{% if ci_host == 'gitlab' %}.gitlab-ci.yml{% endif %}.jinja index 364a155..90cdc2a 100644 --- a/template/project/.gitlab-ci.yml.jinja +++ b/template/project/{% if ci_host == 'gitlab' %}.gitlab-ci.yml{% endif %}.jinja @@ -32,7 +32,9 @@ frontend-build: image: node:22-slim script: - cd frontend - - npm install --no-fund --no-audit + # npm ci once the lockfile is committed (reproducible, installs the + # platform-specific optional deps reliably); plain install before that. + - if [ -f package-lock.json ]; then npm ci --no-fund --no-audit; else npm install --no-fund --no-audit; fi - npm run build cache: key: