feat(release): automate versioned releases on main

This commit is contained in:
2026-09-17 18:41:53 +02:00
parent dd37622bb6
commit 92859f3ccf
39 changed files with 7365 additions and 243 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
**/node_modules
**/.nuxt
**/.output
**/dist
**/.env
**/.env.*
!**/.env.example
.release
gmail-puett

View File

@@ -1,4 +1,6 @@
# FEDEO Selfhosting # FEDEO Selfhosting
# Feste Release-Version, z. B. 1.0.0. dev behält den bisherigen Entwicklungskanal bei.
FEDEO_VERSION=dev
DOMAIN=app.example.com DOMAIN=app.example.com
CONTACT_EMAIL=admin@deine-domain.de CONTACT_EMAIL=admin@deine-domain.de

View File

@@ -1,233 +1,53 @@
name: Build and Push Docker Images name: Build and Release FEDEO
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
on: [push] on:
push:
env: branches: [dev, main]
# Passe dies an deine Registry an. workflow_dispatch:
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
# Beispiel: gitea.deine-domain.de
REGISTRY_HOST: git.federspiel.tech
# Der Name des Repos (z.B. user/repo).
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
IMAGE_NAME: flfeders/fedeo
ACTOR: flfeders
jobs: jobs:
# verify-docs-sync: verify:
# runs-on: ubuntu-latest
# steps:
# - name: Check out repository code
# uses: actions/checkout@v3
#
# - name: Prüfe Node-Version
# uses: actions/setup-node@v4
# with:
# node-version: 20
#
# - name: Synchronisiere Funktionsdokumentation
# run: node docs/scripts/sync-funktionsdoku.mjs
#
# - name: Breche ab, wenn Doku nicht aktuell committed ist
# run: |
# if [ -n "$(git status --porcelain docs/)" ]; then
# echo "Die generierte Dokumentation ist nicht aktuell."
# echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
# echo "Danach die Änderungen committen."
# git status --short docs/
# exit 1
# fi
build-backend:
#needs: verify-docs-sync
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with: with:
registry: ${{ env.REGISTRY_HOST }} ref: ${{ github.sha }}
# Gitea stellt secrets.GITHUB_TOKEN automatisch bereit (wie GitLab CI_JOB_TOKEN) fetch-depth: 0
username: ${{ env.ACTOR }} - uses: actions/setup-node@v4
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Backend
id: meta-backend
uses: docker/metadata-action@v4
with: with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/backend node-version: '22'
tags: | - run: npm ci --ignore-scripts --no-audit --no-fund
type=ref,event=branch - name: Test release planning and recovery
type=ref,event=tag run: npm test
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Check Conventional Commits
run: npm run commits:check
- name: Build and push Backend build-and-release:
uses: docker/build-push-action@v4 needs: verify
with:
context: ./backend # Hier wird der Ordner gewechselt (wie 'cd backend')
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
build-frontend:
#needs: verify-docs-sync
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with: with:
registry: ${{ env.REGISTRY_HOST }} ref: ${{ github.sha }}
username: ${{ env.ACTOR }} fetch-depth: 0
password: ${{ vars.CI_TOKEN }} # Prefer repository secrets; retain the existing variable during migration.
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Frontend - uses: actions/setup-node@v4
id: meta-frontend
uses: docker/metadata-action@v4
with: with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/frontend node-version: '22'
tags: | - run: npm ci --ignore-scripts --no-audit --no-fund
type=ref,event=branch - uses: docker/setup-buildx-action@v3
type=ref,event=tag - uses: docker/login-action@v3
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Frontend
uses: docker/build-push-action@v4
with: with:
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend') registry: git.federspiel.tech
push: true username: flfeders
tags: ${{ steps.meta-frontend.outputs.tags }} password: ${{ secrets.CI_TOKEN || vars.CI_TOKEN }}
labels: ${{ steps.meta-frontend.outputs.labels }} - name: Build all images and publish main releases
# Gitea 1.25 ignores concurrency. ci.mjs uses an atomic Git ref lock instead.
build-docs: env:
needs: verify-docs-sync CI: 'true'
runs-on: ubuntu-latest FEDEO_REF: ${{ github.ref }}
steps: FEDEO_SHA: ${{ github.sha }}
- name: Check out repository code FEDEO_EVENT_NAME: ${{ github.event_name }}
uses: actions/checkout@v3 GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
run: npm run release:ci
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Docs
id: meta-docs
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docs
uses: docker/build-push-action@v4
with:
context: .
file: ./docs-site/Dockerfile
push: true
tags: ${{ steps.meta-docs.outputs.tags }}
labels: ${{ steps.meta-docs.outputs.labels }}
build-website:
#needs: verify-docs-sync
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata (tags, labels) for Website
id: meta-website
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/website
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Website
uses: docker/build-push-action@v4
with:
context: ./website
push: true
tags: ${{ steps.meta-website.outputs.tags }}
labels: ${{ steps.meta-website.outputs.labels }}
build-central-services-api:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata for Central Services API
id: meta-central-api
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-api
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Central Services API
uses: docker/build-push-action@v4
with:
context: ./push-server
file: ./push-server/apps/api/Dockerfile
push: true
tags: ${{ steps.meta-central-api.outputs.tags }}
labels: ${{ steps.meta-central-api.outputs.labels }}
build-central-services-admin:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Log in to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.ACTOR }}
password: ${{ vars.CI_TOKEN }}
- name: Extract metadata for Central Services Admin
id: meta-central-admin
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/central-services-admin
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Central Services Admin
uses: docker/build-push-action@v4
with:
context: ./push-server
file: ./push-server/apps/admin/Dockerfile
push: true
tags: ${{ steps.meta-central-admin.outputs.tags }}
labels: ${{ steps.meta-central-admin.outputs.labels }}

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
node_modules/ node_modules/
.nuxt/ .nuxt/
.output/ .output/
/.release/
# Lokale Runtime-Daten und generierte Konfigurationen # Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/ matrix/postgres/

21
AGENTS.md Normal file
View File

@@ -0,0 +1,21 @@
# Projektanweisungen für FEDEO
## Git und Commit-Nachrichten
- Neue Projektdateien immer gezielt mit `git add` aufnehmen.
- Nach einem abgeschlossenen Arbeitsschritt die eigenen Änderungen committen. Bereits vorhandene Änderungen anderer Aufgaben nicht mitcommitten oder zurücksetzen.
- Jede selbst verfasste Commit-Nachricht muss Conventional Commits verwenden: `type(scope): Beschreibung`. Der Scope ist optional.
- `fix:` für Fehlerkorrekturen (Patch), `feat:` für neue Funktionen (Minor), `perf:` für Leistungsverbesserungen (Patch).
- `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` und `revert:` passend verwenden. Reine Wartungsänderungen lösen standardmäßig kein Release aus; Reverts können einen Patch auslösen.
- Inkompatible Änderungen mit `!` im Betreff und einem Footer `BREAKING CHANGE: Beschreibung und Migrationshinweis` markieren (Major). Diese Kennzeichnung niemals weglassen, um einen Versionssprung zu vermeiden.
- Beispiele: `fix(accounting): validate invoice totals`, `feat(stock): add inventory transfers`, `feat(api)!: replace legacy authentication`.
- Auch selbst verfasste Squash- und Merge-Betreffzeilen entsprechend benennen. Die Release-Auswertung liest die ursprünglichen Commits und überspringt echte Merge-Commits.
## Releases
- `dev` ist der Entwicklungszweig. Ein Push auf `main` ist der vom Benutzer gewählte Release-Auslöser; der Gitea-Workflow prüft, baut und veröffentlicht automatisch.
- Änderungen regulär auf dem aktuellen Entwicklungszweig committen. Erst bei einem ausdrücklichen Release-/Merge-/Push-Auftrag nach `main` übernehmen und pushen.
- Versionsnummern werden aus den Git-Tags `vMAJOR.MINOR.PATCH` und den Commit-Nachrichten berechnet. Keine manuellen Release-Commits oder Änderungen an Paketversionen für Backend/Frontend erzeugen.
- Vor Release-Arbeiten `docs/releases.md` lesen. Vorschau: `npm ci && npm run release:preview`. Prüfungen: `npm test && npm run commits:check`.
- Veröffentlichte Versions-Tags und Images niemals überschreiben oder löschen. Einen fehlgeschlagenen Release anhand der dokumentierten Wiederaufnahme fortsetzen.
- Mobile-App und Device-Agent behalten eigene Paketversionen; ihr Stand wird im Release-Manifest dokumentiert. App-Store-Veröffentlichungen haben einen eigenen Auslöser.

11
CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# FEDEO Versionshistorie
Die Release-Pipeline erzeugt die Versionshistorie automatisch aus Conventional Commits.
- [Alle veröffentlichten Releases mit Änderungslisten](https://git.federspiel.tech/flfeders/FEDEO/releases)
- Jeder Release enthält seine Änderungen zusätzlich als herunterladbare `CHANGELOG.md`.
- `release-manifest.json` ordnet Version, Commit, Docker-Digests, Komponenten und Migrationen eindeutig zu.
Die Versionshistorie vor Einführung der Releases bleibt in der Git-Historie erhalten. Die automatische Reihe beginnt mit `v1.0.0`; Ausgangspunkt ist der in `release.config.json` dokumentierte Commit.
Die Pipeline schreibt keine automatischen Changelog-Commits zurück nach `main`. Dadurch bleiben Merge-Historie und Release-Auslöser eindeutig.

View File

@@ -2,6 +2,14 @@
Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO. Diese Anleitung beschreibt ein produktionsnahes Self-Hosting von FEDEO mit Docker Compose, Traefik, PostgreSQL und optionalem S3-kompatiblem Objektspeicher via MinIO.
## Versionen und Releases
Ein Push auf `main` veröffentlicht nach erfolgreichen Prüfungen und Builds automatisch die nächste FEDEO-Version. Die Versionsnummer und Release Notes entstehen aus Conventional Commits (`fix:`, `feat:`, Breaking Changes). `dev` bleibt der Entwicklungskanal.
Für feste Selfhost-Versionen `FEDEO_VERSION` in `.env` auf eine veröffentlichte Version wie `1.0.0` setzen und die Betriebsdateien aus demselben Release verwenden. Ohne gesetzte Version bleibt der bisherige `dev`-Kanal aktiv. Die folgenden `raw/branch/dev`-Beispiele beschreiben diesen Entwicklungskanal.
Release-Freigabe, Einrichtung der CI-Tokens, versionierte Installation, Updates und Wiederaufnahme sind unter [Versionierung und Releases](docs/releases.md) beschrieben. Die laufende Backend-Version liefert `/backend/version`; im Frontend steht sie unten in der Seitenleiste.
## Architektur ## Architektur
Der Stack besteht aus: Der Stack besteht aus:

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.env
.env.*
!.env.example
*.log

View File

@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apt-get update \ RUN apt-get update \
@@ -13,15 +13,18 @@ RUN apt-get update \
COPY package*.json ./ COPY package*.json ./
# Dev + Prod Dependencies (für TS-Build nötig). # Dev + Prod Dependencies (für TS-Build nötig).
# Sharp benötigt im Linux-Container native optionale Pakete, auch wenn das Lockfile auf macOS erzeugt wurde. # Optionale native Linux-Pakete werden aus dem Lockfile installiert.
RUN npm install --include=optional \ RUN npm ci --include=optional
&& npm install --include=optional --os=linux --cpu=x64 sharp
# Restlicher Sourcecode # Restlicher Sourcecode
COPY . . COPY . .
# TypeScript Build # TypeScript Build
RUN npm run build ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
RUN npm run build && node --import tsx --test tests/*.test.ts
# Port freigeben # Port freigeben
EXPOSE 3100 EXPOSE 3100

View File

@@ -5794,6 +5794,44 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@infisical/sdk": { "node_modules/@infisical/sdk": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz", "resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz",

View File

@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"
import { centralServicesClient } from "./push-server.client" import { centralServicesClient } from "./push-server.client"
import { secrets } from "../utils/secrets" import { secrets } from "../utils/secrets"
import { getBuildInfo } from "../utils/buildInfo"
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000 const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
@@ -31,7 +32,7 @@ export function startCentralServicesHeartbeat(server: FastifyInstance) {
const sendHeartbeat = async () => { const sendHeartbeat = async () => {
try { try {
const response = await centralServicesClient.heartbeat({ const response = await centralServicesClient.heartbeat({
fedeoVersion: secrets.FEDEO_VERSION || "selfhost", fedeoVersion: getBuildInfo().version,
baseUrl: instanceBaseUrl(), baseUrl: instanceBaseUrl(),
}) })
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`) console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)

View File

@@ -1,6 +1,12 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { getBuildInfo } from "../utils/buildInfo";
export default async function routes(server: FastifyInstance) { export default async function routes(server: FastifyInstance) {
server.get("/version", async (_request, reply) => {
reply.header("Cache-Control", "no-store");
return getBuildInfo();
});
server.get("/ping", async () => { server.get("/ping", async () => {
// Testquery gegen DB // Testquery gegen DB
const result = await server.db.execute("SELECT NOW()"); const result = await server.db.execute("SELECT NOW()");
@@ -10,4 +16,4 @@ export default async function routes(server: FastifyInstance) {
db: JSON.stringify(result.rows[0]), db: JSON.stringify(result.rows[0]),
}; };
}); });
} }

View File

@@ -0,0 +1,7 @@
export function getBuildInfo() {
return {
product: "FEDEO",
version: process.env.FEDEO_VERSION || "0.0.0-dev",
revision: process.env.FEDEO_REVISION || "unknown",
};
}

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import Fastify from 'fastify';
import healthRoutes from '../src/routes/health';
test('version endpoint reports image metadata without a database or authentication', async () => {
const previousVersion = process.env.FEDEO_VERSION;
const previousRevision = process.env.FEDEO_REVISION;
process.env.FEDEO_VERSION = '1.2.3';
process.env.FEDEO_REVISION = 'a'.repeat(40);
const app = Fastify();
try {
await app.register(healthRoutes);
const response = await app.inject({ method: 'GET', url: '/version' });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.json(), { product: 'FEDEO', version: '1.2.3', revision: 'a'.repeat(40) });
} finally {
await app.close();
if (previousVersion === undefined) delete process.env.FEDEO_VERSION;
else process.env.FEDEO_VERSION = previousVersion;
if (previousRevision === undefined) delete process.env.FEDEO_REVISION;
else process.env.FEDEO_REVISION = previousRevision;
}
});

View File

@@ -83,7 +83,7 @@ services:
- internal - internal
backend: backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
container_name: fedeo-backend container_name: fedeo-backend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -174,7 +174,7 @@ services:
- internal - internal
frontend: frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
container_name: fedeo-frontend container_name: fedeo-frontend
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View File

@@ -1,6 +1,6 @@
services: services:
frontend: frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
restart: always restart: always
environment: environment:
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend - NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
@@ -22,7 +22,7 @@ services:
- "traefik.http.routers.fedeo-frontend-secure.priority=1" - "traefik.http.routers.fedeo-frontend-secure.priority=1"
docs: docs:
image: git.federspiel.tech/flfeders/fedeo/docs:dev image: git.federspiel.tech/flfeders/fedeo/docs:${FEDEO_VERSION:-dev}
restart: always restart: always
networks: networks:
- traefik - traefik
@@ -43,7 +43,7 @@ services:
- "traefik.http.routers.fedeo-docs-secure.priority=120" - "traefik.http.routers.fedeo-docs-secure.priority=120"
backend: backend:
image: git.federspiel.tech/flfeders/fedeo/backend:dev image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
restart: always restart: always
environment: environment:
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf - INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app/docs-site WORKDIR /app/docs-site
COPY docs-site/package.json docs-site/package-lock.json ./ COPY docs-site/package.json docs-site/package-lock.json ./
@@ -7,9 +7,17 @@ RUN npm ci
COPY docs-site ./ COPY docs-site ./
COPY docs /app/docs COPY docs /app/docs
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
RUN npm run build RUN npm run build
FROM node:20-alpine AS runner FROM node:22-alpine AS runner
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app/docs-site WORKDIR /app/docs-site
ENV NODE_ENV=production ENV NODE_ENV=production

140
docs/releases.md Normal file
View File

@@ -0,0 +1,140 @@
# Versionierung und Releases
## Normaler Ablauf
Entwicklung erfolgt auf `dev`. Ein Push nach `main` ist die bewusste Freigabe zum Veröffentlichen. Der Workflow `.gitea/workflows/build-push.yaml` startet automatisch, prüft die Commit-Nachrichten und Release-Tests, baut alle sechs Images und veröffentlicht bei relevanten Änderungen einen Gitea-Release.
Nach abgeschlossener Entwicklung in einem sauberen Checkout:
```bash
git switch dev
git pull --ff-only origin dev
npm ci --ignore-scripts
npm test
npm run release:preview
git push origin dev
git switch main
git pull --ff-only origin main
git merge --no-ff dev -m "chore(release): merge dev into main"
git push origin main
git switch dev
```
Alternativ einen Pull Request von `dev` nach `main` mit einem normalen Merge übernehmen. Der Merge-Commit selbst wird bei der Versionsberechnung übersprungen; die enthaltenen `feat:`-/`fix:`-Commits bestimmen das Release. Bei Squash-Merges muss die Squash-Nachricht die größte Änderung einschließlich etwaiger Breaking Changes ausdrücken.
Es ist kein separater Versions-Commit erforderlich. `npm run release:preview` ist eine lokale, schreibfreie Vorschau; vorher mit `git fetch origin --tags` die Release-Tags aktualisieren. Ein Push, der nur Wartungsänderungen enthält, erzeugt nach dem ersten Release keine neue Version.
Ein manueller Start ist zusätzlich unter Gitea → Actions → Build and Release FEDEO → Run workflow möglich. Wähle `main` für einen Release und `dev` für einen Entwicklungsbuild. Dieser Start verwendet dieselbe Pipeline und dieselbe automatisch berechnete Version. `npm run release:ci` ist ausschließlich für die CI vorgesehen.
## Versionsregeln
Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer` und `@semantic-release/release-notes-generator` mit dem Conventional-Commits-Preset. Der Veröffentlichungsablauf ist auf Gitea 1.25.3 abgestimmt.
| Commit | Bedeutung | Release |
| --- | --- | --- |
| `fix(scope): …` | Fehlerkorrektur | Patch |
| `perf(scope): …` | Leistungsverbesserung | Patch |
| `feat(scope): …` | Neue Funktion | Minor |
| `feat(scope)!: …` / `BREAKING CHANGE: …` | Inkompatible Änderung | Major |
| `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, `style:` | Wartung ohne Breaking Change | Keiner |
| `revert: …` mit Git-Revert-Information | Zurücknahme bereits ausgelieferter Änderungen | Patch |
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
Der erste Release heißt `1.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung. Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Den Ausgangscommit nach Einführung nicht ändern.
## Versionen und Artefakte
Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemeinsamer FEDEO-Stand gebaut. Die Produktversion stammt aus Git und wird beim Build eingebettet. Backend-/Frontend-Paketversionen dienen nicht als Release-Quelle.
- Stabile Images: `…/backend:1.2.0`, `…/backend:v1.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten.
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>`-Images. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
- Jeder Build erhält zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh` und `SHA256SUMS`.
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
Mobile-App und Device-Agent behalten ihre eigenen bestehenden Versionsreihen. Ihre Paketversionen und die lokale iOS-Buildnummer werden im Manifest festgehalten. Die iOS-Buildnummer aus einer externen EAS-Ausführung kann davon abweichen. Ein Server-Release startet keine App-Store-Veröffentlichung und behauptet keine automatisch geprüfte Kompatibilität mit unabhängig veröffentlichten Clients. Inkompatible API-Änderungen gehören als Breaking Change mit betroffenen Client-Versionen und Upgrade-Anleitung in die Release Notes.
## Laufende Version abfragen
Im Frontend steht die Version unten in der Seitenleiste; der Tooltip enthält den Commit. Die Backend-Version ist öffentlich über diesen Endpunkt abrufbar:
```bash
curl https://app.example.com/backend/version
```
Beispiel: `{"product":"FEDEO","version":"1.2.0","revision":"<Commit-ID>"}`. Die zentrale Instanzmeldung verwendet dieselbe Backend-Version. Bei lokalen Builds ohne Build-Argumente lautet die Version `0.0.0-dev`.
## Gitea einmalig vorbereiten
Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau.
Unter Repository → Settings → Actions → Secrets:
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die bisherige Variable `vars.CI_TOKEN` wird als Übergang weiterhin unterstützt; langfristig als Secret hinterlegen.
- Optional `RELEASE_TOKEN`: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen der temporären Sperr-Branches. Ohne separates Token wird `CI_TOKEN` dafür verwendet und benötigt dann zusätzlich diese Rechte.
Der Token-Benutzer muss die entsprechenden Repository-Rechte haben. Ein Gitea-Standard-Job-Token reicht je nach Server-Konfiguration nicht zum Pushen und zum Registry-Upload. Geschützte Tags `v*` müssen den Release-Benutzer zulassen; die Branches `automation/release-lock` und `automation/dev-build-lock` müssen durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`.
`dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet.
## Veröffentlichungsreihenfolge und Wiederaufnahme
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Für Entwicklungsbuilds gibt es eine separate Sperre. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen.
2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job.
3. Gitea-Entwurf erzeugen und Dateien hochladen, zuerst das Manifest mit den gebauten Digests.
4. Unveränderliche Image-Tags auf diese Digests setzen. Ein vorhandener abweichender Digest führt zum Abbruch.
5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit.
6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen.
Bei einem Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen.
Nach einem hart abgebrochenen Runner kann die Sperre bestehen bleiben. Zuerst in Actions sicherstellen, dass der zugehörige Lauf wirklich beendet ist. Danach ausschließlich dessen Sperre mit dem zuvor gelesenen Commit als Lease entfernen:
```bash
git ls-remote origin refs/heads/automation/release-lock
# Die ausgegebene Commit-ID anstelle von LOCK_SHA einsetzen:
git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock
```
Für einen abgebrochenen Entwicklungsbuild entsprechend `automation/dev-build-lock` verwenden. Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird.
## Selfhosting und Updates
Für eine Installation eines bereits veröffentlichten Releases, hier beispielhaft `1.0.0`:
```bash
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/releases/download/v1.0.0/selfhost-install.sh -o /tmp/fedeo-install.sh
bash /tmp/fedeo-install.sh --simple --start
```
Dieser versionierte Installer lädt die Betriebsdateien vom zugehörigen Git-Tag und schreibt `FEDEO_VERSION=1.0.0` in die neue `.env`. Vorhandene Installationen behalten ihren bisherigen Kanal, bis `FEDEO_VERSION` bewusst geändert wird. Die Dateien im Entwicklungszweig verwenden weiterhin `dev` als Rückfallwert.
Bei einem Update:
1. Release Notes und Migrationshinweise lesen. Konsistentes Datenbankbackup (z. B. `pg_dump`) sowie Datei-/Objektspeicher und Konfiguration sichern. Ein TAR des laufenden PostgreSQL-Datenverzeichnisses ist kein verlässliches Datenbankbackup.
2. Die benötigten Betriebsdateien vom gewünschten Release herunterladen; bestehende `.env` und Daten behalten. Downloads mit dessen `SHA256SUMS` überprüfen.
3. In `/opt/fedeo/.env` die feste Zielversion setzen, z. B. `FEDEO_VERSION=1.2.0`.
4. Images laden und Dienste neu erstellen:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
curl https://app.example.com/backend/version
```
Das Backend führt standardmäßig Vorwärtsmigrationen beim Start aus. Ein Downgrade der Images setzt die Datenbank nicht zurück. Zur vorherigen Version nur bei nachgewiesener Schema-Kompatibilität wechseln oder das passende Backup in einem geplanten Wiederherstellungsablauf einspielen.
## Technische Referenzen
- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- [semantic-release Commit-Analyse](https://github.com/semantic-release/commit-analyzer)
- [Gitea 1.25 Actions-Einschränkungen](https://docs.gitea.com/1.25/usage/actions/comparison/)
- [Gitea 1.25 API](https://docs.gitea.com/api/1.25/)
- [Codex-Projektanweisungen mit AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)

8
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.nuxt
.output
.git
.env
.env.*
!.env.example
*.log

View File

@@ -1,18 +1,26 @@
# --- Stage 1: Build --- # --- Stage 1: Build ---
FROM node:20-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /usr/src/nuxt-app WORKDIR /usr/src/nuxt-app
# Nur Files kopieren, die für die Installation nötig sind (besseres Caching) # Nur Files kopieren, die für die Installation nötig sind (besseres Caching)
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
# Restlichen Code kopieren und bauen # Restlichen Code kopieren und bauen
COPY . . COPY . .
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
RUN npm run build RUN npm run build
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
FROM node:20-alpine AS runner FROM node:22-alpine AS runner
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /usr/src/nuxt-app WORKDIR /usr/src/nuxt-app
@@ -28,4 +36,4 @@ ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["node", ".output/server/index.mjs"] ENTRYPOINT ["node", ".output/server/index.mjs"]

View File

@@ -12,6 +12,7 @@ const colorMode = useColorMode()
const {isHelpSlideoverOpen} = useDashboard() const {isHelpSlideoverOpen} = useDashboard()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const buildInfo = useRuntimeConfig().public
const auth = useAuthStore() const auth = useAuthStore()
const labelPrinter = useLabelPrinterStore() const labelPrinter = useLabelPrinterStore()
@@ -298,6 +299,9 @@ onMounted(() => {
</UButton> </UButton>
</div> </div>
<p v-if="!collapsed" class="px-2.5 text-xs text-gray-500 dark:text-gray-400" :title="buildInfo.fedeoRevision">
FEDEO {{ buildInfo.fedeoVersion }}
</p>
<USeparator class="sticky bottom-0 w-full"/> <USeparator class="sticky bottom-0 w-full"/>
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/> <UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>
</div> </div>

View File

@@ -81,6 +81,8 @@ export default defineNuxtConfig({
public: { public: {
apiBase: '', apiBase: '',
pdfLicense: '', pdfLicense: '',
fedeoVersion: process.env.FEDEO_VERSION || '0.0.0-dev',
fedeoRevision: process.env.FEDEO_REVISION || 'unknown',
} }
}, },

View File

@@ -3741,6 +3741,18 @@
} }
} }
}, },
"node_modules/@nuxt/cli/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@nuxt/devalue": { "node_modules/@nuxt/devalue": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",

6225
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "fedeo-release-tools",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"test": "node --test scripts/release/tests/*.test.mjs",
"commits:check": "node scripts/release/cli.mjs check",
"release:preview": "node scripts/release/cli.mjs preview",
"release:ci": "node scripts/release/ci.mjs"
},
"devDependencies": {
"@semantic-release/commit-analyzer": "13.0.1",
"@semantic-release/release-notes-generator": "14.1.0",
"conventional-changelog-conventionalcommits": "9.1.0",
"semver": "7.7.2"
}
}

View File

@@ -2,13 +2,17 @@ FROM node:22-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
COPY apps/admin/package.json apps/admin/package.json COPY apps/admin/package.json apps/admin/package.json
RUN npm install RUN npm ci
FROM deps AS build FROM deps AS build
COPY . . COPY . .
RUN npm run build --workspace @fedeo/push-admin RUN npm run build --workspace @fedeo/push-admin
FROM node:22-alpine FROM node:22-alpine
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /app/apps/admin/.output ./.output COPY --from=build /app/apps/admin/.output ./.output

View File

@@ -3,13 +3,17 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
COPY apps/api/package.json apps/api/package.json COPY apps/api/package.json apps/api/package.json
COPY packages/db/package.json packages/db/package.json COPY packages/db/package.json packages/db/package.json
RUN npm install RUN npm ci
FROM deps AS build FROM deps AS build
COPY . . COPY . .
RUN npm run build --workspace @fedeo/push-db && npm run build --workspace @fedeo/push-api RUN npm run build --workspace @fedeo/push-db && npm run build --workspace @fedeo/push-api
FROM node:22-alpine FROM node:22-alpine
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV DRIZZLE_MIGRATIONS_FOLDER=/app/packages/db/drizzle ENV DRIZZLE_MIGRATIONS_FOLDER=/app/packages/db/drizzle

15
release.config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"initialVersion": "1.0.0",
"bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18",
"branch": "main",
"repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO",
"registry": "git.federspiel.tech/flfeders/fedeo",
"images": [
{ "name": "backend", "context": "backend", "dockerfile": "backend/Dockerfile" },
{ "name": "frontend", "context": "frontend", "dockerfile": "frontend/Dockerfile" },
{ "name": "docs", "context": ".", "dockerfile": "docs-site/Dockerfile" },
{ "name": "website", "context": "website", "dockerfile": "website/Dockerfile" },
{ "name": "central-services-api", "context": "push-server", "dockerfile": "push-server/apps/api/Dockerfile" },
{ "name": "central-services-admin", "context": "push-server", "dockerfile": "push-server/apps/admin/Dockerfile" }
]
}

112
scripts/release/ci.mjs Normal file
View File

@@ -0,0 +1,112 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createPlan, git, readConfig, stableTag } from './lib.mjs';
import { Gitea } from './gitea.mjs';
import { acquireLock } from './lock.mjs';
import { publishRelease } from './publish.mjs';
const config = readConfig();
const outputDir = '.release';
let unlock;
function docker(args, capture = false) {
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 16 * 1024 * 1024 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Docker ${args.slice(0, 3).join(' ')} fehlgeschlagen${capture ? `: ${result.stderr}` : '.'}`);
return result.stdout?.trim();
}
function imageDigest(reference, optional = false) {
try {
return JSON.parse(docker(['buildx', 'imagetools', 'inspect', reference, '--format', '{{json .Manifest}}'], true)).digest;
} catch (error) {
if (optional && /manifest unknown|not found|no such manifest/i.test(error.message)) return null;
throw error;
}
}
async function promote(image, tags, immutable) {
for (const tag of tags) {
const reference = `${image.repository}:${tag}`;
if (immutable) {
const existing = imageDigest(reference, true);
if (existing === image.digest) continue;
if (existing) throw new Error(`Image ${reference} existiert mit anderem Digest; Überschreiben verweigert.`);
}
docker(['buildx', 'imagetools', 'create', '--prefer-index=false', '--tag', reference, `${image.repository}@${image.digest}`]);
if (imageDigest(reference) !== image.digest) throw new Error(`Digest-Prüfung für ${reference} fehlgeschlagen.`);
}
}
async function buildImages(plan, version, reuseTag) {
const stagingTag = `build-${randomUUID()}`;
const images = [];
for (const component of config.images) {
const repository = `${config.registry}/${component.name}`;
const existing = reuseTag ? imageDigest(`${repository}:${reuseTag}`, true) : null;
if (existing) {
images.push({ name: component.name, repository, digest: existing });
continue;
}
const metadata = `${outputDir}/${component.name}.json`;
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
'--file', component.dockerfile, '--metadata-file', metadata, '--tag', `${repository}:${stagingTag}`,
'--build-arg', `FEDEO_VERSION=${version}`, '--build-arg', `FEDEO_REVISION=${plan.sha}`,
'--label', `org.opencontainers.image.version=${version}`, '--label', `org.opencontainers.image.revision=${plan.sha}`,
'--label', `org.opencontainers.image.source=${config.repositoryUrl}`, component.context]);
const digest = JSON.parse(readFileSync(metadata, 'utf8'))['containerimage.digest'];
if (!/^sha256:[a-f0-9]{64}$/.test(digest || '')) throw new Error(`Fehlender Digest für ${component.name}.`);
images.push({ name: component.name, repository, digest });
}
return images;
}
try {
const ref = process.env.FEDEO_REF;
if (process.env.CI !== 'true' || !['push', 'workflow_dispatch'].includes(process.env.FEDEO_EVENT_NAME) ||
![`refs/heads/${config.branch}`, 'refs/heads/dev'].includes(ref)) {
throw new Error('Veröffentlichung nur im Gitea-Workflow auf dev/main. Lokal: npm run release:preview.');
}
const sha = git(['rev-parse', 'HEAD']);
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.');
const token = process.env.GITEA_TOKEN;
if (!token) throw new Error('CI_TOKEN oder RELEASE_TOKEN als Gitea-Secret konfigurieren (siehe docs/releases.md).');
mkdirSync(outputDir, { recursive: true });
const api = new Gitea(config.repositoryUrl, token);
const stable = ref === `refs/heads/${config.branch}`;
unlock = await acquireLock(stable ? {} : { ref: 'refs/heads/automation/dev-build-lock' });
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
if (stable) {
const releases = await api.releases();
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha);
if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
console.log('Ein neuerer main-Commit liegt vor. Dessen Workflow übernimmt die ausstehenden Änderungen.');
} else {
const plan = await createPlan();
writeFileSync(`${outputDir}/plan.json`, JSON.stringify(plan, null, 2));
if (!plan.version) console.log('Keine releasewirksamen Änderungen seit dem letzten Tag.');
else {
const result = await publishRelease({ plan, config, api, build: version => buildImages(plan, version), promote });
writeFileSync(`${outputDir}/release-manifest.json`, JSON.stringify(result.manifest, null, 2));
console.log(`FEDEO ${plan.version} veröffentlicht: ${config.repositoryUrl}/releases/tag/${plan.tag}`);
}
}
} else {
const plan = await createPlan();
const images = await buildImages(plan, plan.developmentVersion, `dev-sha-${sha}`);
for (const image of images) await promote(image, [`dev-sha-${sha}`], true);
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
if (remoteDev === sha) for (const image of images) await promote(image, ['dev'], false);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
if (unlock) {
try { unlock(); }
catch (error) { console.error(`Release-Sperre konnte nicht entfernt werden: ${error.message}`); process.exitCode = 1; }
}
}

21
scripts/release/cli.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { assertBootstrap, checkCommits, commitsSince, createPlan, readConfig } from './lib.mjs';
try {
const command = process.argv[2];
if (command === 'check') {
const config = readConfig();
assertBootstrap(config, 'HEAD', process.cwd());
const commits = commitsSince(config.bootstrapCommit);
checkCommits(commits);
console.log(`${commits.length} Commit-Nachrichten geprüft.`);
} else if (command === 'preview') {
const plan = await createPlan();
console.log(JSON.stringify(plan, null, 2));
console.log('\nLokale Vorschau; berücksichtigt die lokal vorhandenen Tags. Zum Aktualisieren: git fetch origin --tags.');
} else {
throw new Error('Aufruf: node scripts/release/cli.mjs check|preview');
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}

68
scripts/release/gitea.mjs Normal file
View File

@@ -0,0 +1,68 @@
export class Gitea {
constructor(repositoryUrl, token, fetchImpl = fetch) {
const url = new URL(repositoryUrl);
this.origin = url.origin;
this.base = `${url.origin}/api/v1/repos${url.pathname.replace(/\/$/, '')}`;
this.token = token;
this.fetch = fetchImpl;
}
async request(path, { method = 'GET', body, missing = false, raw = false } = {}) {
const response = await this.fetch(`${this.base}${path}`, {
method, redirect: 'error', signal: AbortSignal.timeout(60_000),
headers: { Authorization: `token ${this.token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
});
if (missing && response.status === 404) return null;
if (!response.ok) throw new Error(`Gitea ${method} ${path}: HTTP ${response.status}`);
if (raw) return Buffer.from(await response.arrayBuffer());
return response.status === 204 ? null : response.json();
}
release(tag) { return this.request(`/releases/tags/${encodeURIComponent(tag)}`, { missing: true }); }
tag(tag) { return this.request(`/tags/${encodeURIComponent(tag)}`, { missing: true }); }
async releases() {
const releases = [];
for (let page = 1; ; page++) {
const batch = await this.request(`/releases?limit=50&page=${page}`);
releases.push(...batch);
if (batch.length < 50) return releases;
}
}
async asset(release, name) {
const assets = await this.request(`/releases/${release.id}/assets`);
const asset = assets.find(item => item.name === name);
if (!asset) return null;
let url = new URL(asset.browser_download_url);
if (url.origin !== this.origin) throw new Error('Unerwartete Herkunft der Release-Datei.');
for (let redirects = 0; redirects < 5; redirects++) {
const response = await this.fetch(url.href, {
redirect: 'manual', signal: AbortSignal.timeout(60_000),
headers: url.origin === this.origin ? { Authorization: `token ${this.token}` } : {},
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
url = new URL(response.headers.get('location'), url);
if (url.protocol !== 'https:') throw new Error('Unsicheres Download-Redirect.');
continue;
}
if (!response.ok) throw new Error(`Release-Download: HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
throw new Error('Zu viele Download-Redirects.');
}
async upload(release, name, data) {
const assets = await this.request(`/releases/${release.id}/assets`);
const existing = assets.find(item => item.name === name);
if (existing) {
const saved = await this.asset(release, name);
if (!saved.equals(Buffer.from(data))) throw new Error(`Release-Datei ${name} existiert bereits mit anderem Inhalt.`);
return;
}
const form = new FormData();
form.append('attachment', new Blob([data]), name);
await this.request(`/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form });
}
}

93
scripts/release/lib.mjs Normal file
View File

@@ -0,0 +1,93 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { analyzeCommits } from '@semantic-release/commit-analyzer';
import { generateNotes } from '@semantic-release/release-notes-generator';
import semver from 'semver';
export const logger = { log() {} };
export const stableTag = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const commitHeader = /^(feat|fix|perf|docs|test|refactor|build|ci|chore|style|revert)(\([^()\r\n]+\))?!?: \S.*$/;
export function git(args, cwd = process.cwd(), options = {}) {
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options }).trim();
}
export function readConfig(cwd = process.cwd()) {
return JSON.parse(readFileSync(resolve(cwd, 'release.config.json'), 'utf8'));
}
export function validCommit(message) {
// Git's generated revert message is understood by the Conventional Commits preset.
return commitHeader.test(message.split('\n')[0]) ||
(/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(message));
}
export function commitsSince(from, to = 'HEAD', cwd = process.cwd()) {
// semantic-release's revert filter expects newest commits first.
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`], cwd);
return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : [];
}
export function checkCommits(commits) {
const invalid = commits.filter(commit => !validCommit(commit.message));
if (invalid.length) throw new Error(`Conventional Commits fehlen:\n${invalid.map(c => `${c.hash.slice(0, 8)} ${c.message.split('\n')[0]}`).join('\n')}\nVerwende z. B. fix(scope): Beschreibung oder feat(scope): Beschreibung.`);
}
export async function releaseType(commits, cwd = process.cwd()) {
return analyzeCommits({ preset: 'conventionalcommits' }, { cwd, commits, logger });
}
export function assertBootstrap(config, target, cwd) {
try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, target], cwd); }
catch { throw new Error('Der Release-Ausgangscommit fehlt in der Historie. Vollständig auschecken (fetch-depth: 0) und dev nach main übernehmen.'); }
}
export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) {
const sha = git(['rev-parse', `${target}^{commit}`], cwd);
assertBootstrap(config, sha, cwd);
const tags = git(['tag', '--merged', sha], cwd).split('\n').filter(tag => stableTag.test(tag))
.sort((a, b) => semver.rcompare(a.slice(1), b.slice(1)));
const lastTag = tags[0];
const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha;
const previousTag = resume ? tags[1] : lastTag;
const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit;
const commits = commitsSince(from, sha, cwd);
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd));
const bump = await releaseType(commits, cwd);
const version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null;
if (version && !semver.valid(version)) throw new Error('Ungültige Release-Version.');
const tag = version ? `v${version}` : null;
const notes = version ? await generateNotes({
preset: 'conventionalcommits', host: new URL(config.repositoryUrl).origin,
commit: 'commit', issue: 'issues', linkCompare: Boolean(previousTag),
}, {
cwd, logger, commits, options: { repositoryUrl: config.repositoryUrl },
lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {},
nextRelease: { version, gitTag: tag, gitHead: sha },
}) : '';
const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`], cwd);
return {
sha, version, tag, previousTag: previousTag || null, from, bump, resume,
developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`,
notes: version ? `${!previousTag ? 'Erster versionierter FEDEO-Release. Die ältere Entwicklungshistorie bleibt im Git-Repository erhalten.\n\n' : ''}${notes}` : '',
};
}
export function componentVersions(sha, cwd = process.cwd()) {
const json = path => JSON.parse(git(['show', `${sha}:${path}`], cwd));
return {
mobile: { version: json('mobile/app.json').expo.version, iosBuildNumber: json('mobile/app.json').expo.ios.buildNumber },
deviceAgent: { version: json('agents/fedeo-device-agent/package.json').version },
};
}
export function migrationManifest(sha, cwd = process.cwd()) {
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => {
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd);
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'),
})) : [];
});
}

25
scripts/release/lock.mjs Normal file
View File

@@ -0,0 +1,25 @@
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { git } from './lib.mjs';
export const lockRef = 'refs/heads/automation/release-lock';
export async function acquireLock({ runGit = git, wait = sleep, attempts = 480, ref = lockRef } = {}) {
const lockSha = runGit(['-c', 'user.name=FEDEO Release', '-c', 'user.email=release@fedeo.de',
'commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `chore(release): lock ${randomUUID()}`]);
for (let attempt = 0; attempt < attempts; attempt++) {
const existing = runGit(['ls-remote', 'origin', ref]);
if (!existing) {
try {
// An empty lease means the ref MUST NOT exist, even if another runner just created it.
runGit(['push', `--force-with-lease=${ref}:`, 'origin', `${lockSha}:${ref}`]);
return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]);
} catch (error) {
if (!runGit(['ls-remote', 'origin', ref])) throw error;
}
}
if (attempt % 4 === 0) console.log('Ein anderer Release läuft; warte auf die Git-Sperre.');
await wait(15_000);
}
throw new Error(`Release-Sperre ${ref} bleibt belegt. Siehe docs/releases.md zur Wiederaufnahme.`);
}

View File

@@ -0,0 +1,86 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { componentVersions, migrationManifest, stableTag } from './lib.mjs';
import semver from 'semver';
export function assertManifest(manifest, plan, config) {
if (manifest.version !== plan.version || manifest.commit !== plan.sha || manifest.tag !== plan.tag ||
manifest.images.length !== config.images.length) throw new Error('Release-Manifest passt nicht zum Commit.');
for (const component of config.images) {
const image = manifest.images.find(item => item.name === component.name);
if (!image || image.repository !== `${config.registry}/${component.name}` || !/^sha256:[a-f0-9]{64}$/.test(image.digest)) {
throw new Error(`Ungültiges Image im Manifest: ${component.name}`);
}
}
}
export function makeManifest(plan, images, config) {
return {
schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha,
previousTag: plan.previousTag, repository: config.repositoryUrl,
images, components: componentVersions(plan.sha), migrations: migrationManifest(plan.sha),
migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.',
};
}
export function releaseFiles(manifest, notes) {
const pin = source => source.replaceAll('${FEDEO_VERSION:-dev}', `\${FEDEO_VERSION:-${manifest.version}}`);
const files = new Map([
['release-manifest.json', Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)],
['CHANGELOG.md', Buffer.from(notes)],
['docker-compose.selfhost.yml', Buffer.from(pin(readFileSync('docker-compose.selfhost.yml', 'utf8')))],
['.env.example', Buffer.from(readFileSync('.env.example', 'utf8').replace(/^FEDEO_VERSION=.*$/m, `FEDEO_VERSION=${manifest.version}`))],
['selfhost-install.sh', Buffer.from(readFileSync('scripts/selfhost-install.sh', 'utf8').replace('FEDEO_REF:-dev', `FEDEO_REF:-${manifest.tag}`))],
['selfhost-setup.sh', Buffer.from(readFileSync('scripts/selfhost-setup.sh', 'utf8').replaceAll('FEDEO_VERSION:-dev', `FEDEO_VERSION:-${manifest.version}`))],
]);
files.set('SHA256SUMS', Buffer.from([...files].map(([name, data]) => `${createHash('sha256').update(data).digest('hex')} ${name}`).join('\n') + '\n'));
return files;
}
// Dependencies are explicit so failure/retry/publication ordering can be tested without a registry.
export async function publishRelease({ plan, config, api, build, promote, files = releaseFiles, manifestFactory = makeManifest }) {
const all = await api.releases();
const pending = all.find(release => release.draft && stableTag.test(release.tag_name) && release.tag_name !== plan.tag);
if (pending) throw new Error(`Zuerst den unvollständigen Release ${pending.tag_name} wiederaufnehmen.`);
const newer = all.find(release => !release.draft && stableTag.test(release.tag_name) && semver.gt(release.tag_name.slice(1), plan.version));
if (newer) throw new Error(`Ein neuerer Release ${newer.tag_name} existiert bereits; kein Downgrade von latest.`);
let release = await api.release(plan.tag);
const tag = await api.tag(plan.tag);
if (tag && tag.commit.sha !== plan.sha) throw new Error(`${plan.tag} zeigt bereits auf einen anderen Commit.`);
if (release && !tag && !release.draft) throw new Error('Release ohne Git-Tag; manuelle Prüfung erforderlich.');
if (release?.draft && release.target_commitish !== plan.sha) throw new Error('Release-Entwurf gehört zu einem anderen Commit.');
let manifest;
let notes = plan.notes;
if (release) {
const saved = await api.asset(release, 'release-manifest.json');
if (saved) manifest = JSON.parse(saved.toString('utf8'));
// Keep the original notes/date on retries, even on a later day.
notes = release.body;
if (!release.draft && !manifest) throw new Error('Veröffentlichter Release ohne Manifest; keine Images überschreiben.');
}
if (!manifest) {
const images = await build(plan.version);
manifest = manifestFactory(plan, images, config);
}
assertManifest(manifest, plan, config);
if (!release) {
release = await api.request('/releases', { method: 'POST', body: {
tag_name: plan.tag, target_commitish: plan.sha, name: `FEDEO ${plan.version}`,
body: notes, draft: true, prerelease: false,
} });
}
if (release.draft) {
// Manifest is first: once any immutable image is promoted, every retry uses these exact digests.
for (const [name, data] of files(manifest, notes)) await api.upload(release, name, data);
for (const image of manifest.images) await promote(image, [plan.version, plan.tag, `sha-${plan.sha}`], true);
release = await api.request(`/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
}
// Gitea creates the Git tag when publishing, not when creating a draft.
const publishedTag = await api.tag(plan.tag);
if (!publishedTag || publishedTag.commit.sha !== plan.sha) throw new Error('Veröffentlichter Git-Tag passt nicht zum Release-Commit.');
// Mutable channels only move after all artifacts exist and the release is published.
// A retry repairs an interrupted channel update without rebuilding any published version.
for (const image of manifest.images) await promote(image, ['latest', 'main'], false);
return { release, manifest };
}

View File

@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { checkCommits, createPlan, git, releaseType, validCommit } from '../lib.mjs';
import { acquireLock, lockRef } from '../lock.mjs';
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-release-test-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
symlinkSync(resolve('node_modules'), join(cwd, 'node_modules'), 'dir');
const run = args => git(args, cwd, { stdio: ['pipe', 'pipe', 'pipe'] });
run(['init', '-b', 'main']);
run(['config', 'user.name', 'Release Test']);
run(['config', 'user.email', 'release@example.test']);
run(['commit', '--allow-empty', '-m', 'historic baseline']);
const config = { bootstrapCommit: run(['rev-parse', 'HEAD']), initialVersion: '1.0.0', repositoryUrl: 'https://git.example.test/owner/fedeo' };
let count = 0;
const commit = message => {
writeFileSync(join(cwd, 'file'), `${++count}\n`);
run(['add', 'file']);
run(['commit', '-m', message]);
return run(['rev-parse', 'HEAD']);
};
return { cwd, config, commit, run, plan: () => createPlan({ cwd, config }) };
}
test('commit types, scopes and breaking headers are enforced', () => {
for (const message of ['fix: repair', 'feat(stock): add transfers', 'feat(api)!: replace auth', 'ci: build', 'revert: restore']) assert.ok(validCommit(message));
for (const message of ['changes', 'Feat: uppercase', 'fix:', 'fix(x) missing colon', 'fix: ']) assert.equal(validCommit(message), false);
assert.throws(() => checkCommits([{ hash: 'abc123', message: 'misc changes' }]), /Conventional Commits/);
});
test('semantic-release determines the highest required bump and handles maintenance', async () => {
const commit = message => ({ hash: 'a'.repeat(40), message });
assert.equal(await releaseType([commit('fix: repair')]), 'patch');
assert.equal(await releaseType([commit('perf: reduce latency')]), 'patch');
assert.equal(await releaseType([commit('fix: repair'), commit('feat: add export')]), 'minor');
assert.equal(await releaseType([commit('feat(api)!: replace auth')]), 'major');
assert.equal(await releaseType([commit('refactor: change API\n\nBREAKING CHANGE: remove old API')]), 'major');
assert.equal(await releaseType([commit('docs: improve guide'), commit('ci: check build')]), null);
});
test('first release uses explicit baseline and excludes legacy history', async t => {
const f = fixture(t);
f.commit('feat: export');
const plan = await f.plan();
assert.equal(plan.version, '1.0.0');
assert.equal(plan.tag, 'v1.0.0');
assert.match(plan.notes, /export/);
assert.doesNotMatch(plan.notes, /historic baseline|compare\/\.\.\./);
});
test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => {
const f = fixture(t);
f.run(['tag', 'v1.9.0']);
f.run(['tag', 'v1.10.0']);
f.run(['tag', 'mobile-v99.0.0']);
f.commit('fix: repair');
assert.equal((await f.plan()).version, '1.10.1');
f.commit('feat: add transfer');
assert.equal((await f.plan()).version, '1.11.0');
f.commit('fix(api)!: remove field');
assert.equal((await f.plan()).version, '2.0.0');
});
test('maintenance does not release; same tagged commit resumes the same version', async t => {
const f = fixture(t);
f.run(['tag', 'v1.0.0']);
f.commit('docs: guide');
assert.equal((await f.plan()).version, null);
f.commit('feat: export');
f.run(['tag', 'v1.1.0']);
const plan = await f.plan();
assert.equal(plan.version, '1.1.0');
assert.equal(plan.resume, true);
assert.equal(plan.previousTag, 'v1.0.0');
assert.match(plan.notes, /export/);
});
test('reverted changes cancel out within the same release range', async t => {
const f = fixture(t);
f.run(['tag', 'v1.0.0']);
const sha = f.commit('feat: temporary feature');
f.run(['revert', '--no-edit', sha]);
assert.equal((await f.plan()).version, null);
});
test('real merge commits do not replace the feature commits in release notes', async t => {
const f = fixture(t);
f.run(['checkout', '-b', 'feature']);
f.commit('feat: new feature');
f.run(['checkout', 'main']);
f.run(['merge', '--no-ff', 'feature', '-m', 'Merge feature']);
const plan = await f.plan();
assert.match(plan.notes, /new feature/);
assert.doesNotMatch(plan.notes, /Merge feature/);
});
test('an unknown baseline fails instead of releasing unrelated history', async t => {
const f = fixture(t);
f.config.bootstrapCommit = 'f'.repeat(40);
await assert.rejects(f.plan(), /Ausgangscommit/);
});
test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => {
const f = fixture(t);
const remote = join(f.cwd, 'remote.git');
f.run(['init', '--bare', remote]);
f.run(['remote', 'add', 'origin', remote]);
const unlock = await acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 });
await assert.rejects(acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 }), /belegt/);
const replacement = f.commit('chore: other lock owner');
f.run(['push', '--force', 'origin', `${replacement}:${lockRef}`]);
assert.throws(unlock);
assert.match(f.run(['ls-remote', 'origin', lockRef]), new RegExp(replacement));
});
test('Git lease lock is released after a successful run', async t => {
const f = fixture(t);
const remote = join(f.cwd, 'remote.git');
f.run(['init', '--bare', remote]);
f.run(['remote', 'add', 'origin', remote]);
const unlock = await acquireLock({ runGit: f.run, attempts: 1 });
unlock();
assert.equal(f.run(['ls-remote', 'origin', lockRef]), '');
});

View File

@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { publishRelease, assertManifest, releaseFiles } from '../publish.mjs';
import { Gitea } from '../gitea.mjs';
function fixture() {
const plan = { version: '1.2.0', tag: 'v1.2.0', sha: 'a'.repeat(40), notes: 'Release notes' };
const config = { registry: 'registry.test/fedeo', images: [{ name: 'backend' }, { name: 'frontend' }] };
const images = config.images.map((item, i) => ({ ...item, repository: `${config.registry}/${item.name}`, digest: `sha256:${String(i).repeat(64)}` }));
const manifest = { version: plan.version, tag: plan.tag, commit: plan.sha, images };
const events = [];
let release = null;
let tag = null;
const assets = new Map();
const api = {
async releases() { return release ? [release] : []; },
async release() { return release; },
async tag() { return tag; },
async asset(_release, name) { return assets.get(name); },
async upload(_release, name, bytes) {
events.push(`upload:${name}`);
if (assets.has(name)) assert.deepEqual(assets.get(name), bytes);
assets.set(name, bytes);
},
async request(_path, { method, body }) {
if (method === 'POST') {
events.push('draft');
// Gitea drafts deliberately do NOT create a tag.
release = { ...body, id: 1 };
} else {
events.push('publish');
release = { ...release, ...body };
tag = { commit: { sha: plan.sha } };
}
return release;
},
};
const dependencies = {
plan, config, api,
build: async () => { events.push('build'); return images; },
promote: async (image, _tags, immutable) => { events.push(`${immutable ? 'version' : 'channel'}:${image.name}`); },
manifestFactory: () => manifest,
files: (saved, notes) => new Map([['release-manifest.json', Buffer.from(JSON.stringify(saved))], ['CHANGELOG.md', Buffer.from(notes)]]),
};
return { plan, config, manifest, api, events, assets, dependencies, getRelease: () => release, setTag: value => { tag = value; } };
}
test('publish only after builds, assets and immutable images; channels move last', async () => {
const f = fixture();
await publishRelease(f.dependencies);
assert.deepEqual(f.events, ['build', 'draft', 'upload:release-manifest.json', 'upload:CHANGELOG.md', 'version:backend', 'version:frontend', 'publish', 'channel:backend', 'channel:frontend']);
});
test('failed build never creates a draft or changes release channels', async () => {
const f = fixture();
f.dependencies.build = async () => { throw new Error('build failed'); };
await assert.rejects(publishRelease(f.dependencies), /build failed/);
assert.deepEqual(f.events, []);
});
test('retry a draft uses saved digests and original notes without rebuilding', async () => {
const f = fixture();
f.dependencies.promote = async () => { throw new Error('registry interrupted'); };
await assert.rejects(publishRelease(f.dependencies), /registry interrupted/);
assert.equal(f.getRelease().draft, true);
assert.equal(await f.api.tag(), null);
f.plan.notes = 'Notes generated on another day';
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
f.dependencies.promote = async () => {};
await publishRelease(f.dependencies);
assert.equal(f.getRelease().draft, false);
assert.equal(f.assets.get('CHANGELOG.md').toString(), 'Release notes');
});
test('retry after publication only repairs mutable channels', async () => {
const f = fixture();
const promote = f.dependencies.promote;
f.dependencies.promote = async (image, tags, immutable) => {
if (!immutable) throw new Error('channel update interrupted');
await promote(image, tags, immutable);
};
await assert.rejects(publishRelease(f.dependencies), /channel update interrupted/);
f.events.length = 0;
f.dependencies.promote = promote;
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
await publishRelease(f.dependencies);
assert.deepEqual(f.events, ['channel:backend', 'channel:frontend']);
});
test('partial asset upload remains a draft and retries safely', async () => {
const f = fixture();
const upload = f.api.upload;
f.api.upload = async (...args) => {
if (args[1] === 'CHANGELOG.md') throw new Error('upload interrupted');
return upload(...args);
};
await assert.rejects(publishRelease(f.dependencies), /upload interrupted/);
assert.equal(f.getRelease().draft, true);
assert.ok(!f.events.includes('publish'));
f.api.upload = upload;
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
await publishRelease(f.dependencies);
});
test('another pending release must be completed first', async () => {
const f = fixture();
f.api.releases = async () => [{ draft: true, tag_name: 'v1.1.0' }];
await assert.rejects(publishRelease(f.dependencies), /unvollständigen Release/);
assert.deepEqual(f.events, []);
});
test('a historical rerun cannot roll latest back', async () => {
const f = fixture();
f.api.releases = async () => [{ draft: false, tag_name: 'v2.0.0' }];
await assert.rejects(publishRelease(f.dependencies), /Downgrade/);
});
test('tag collisions and manifests with wrong digests or commits are rejected', async () => {
const f = fixture();
f.setTag({ commit: { sha: 'b'.repeat(40) } });
await assert.rejects(publishRelease(f.dependencies), /anderen Commit/);
assert.throws(() => assertManifest({ ...f.manifest, commit: 'bad' }, f.plan, f.config));
const malformed = structuredClone(f.manifest);
malformed.images[0].digest = 'latest';
assert.throws(() => assertManifest(malformed, f.plan, f.config));
});
test('release assets pin compose, environment and installer to one version', () => {
const f = fixture();
const files = releaseFiles(f.manifest, f.plan.notes);
assert.match(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-1\.2\.0/);
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/);
assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/);
assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
});
test('Gitea API differentiates missing resources from permission and server errors', async () => {
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async () => new Response('', { status: 404 }));
assert.equal(await api.release('v1.0.0'), null);
api.fetch = async () => new Response('', { status: 403 });
await assert.rejects(api.release('v1.0.0'), /HTTP 403/);
api.fetch = async () => new Response('', { status: 500 });
await assert.rejects(api.release('v1.0.0'), /HTTP 500/);
});
test('Gitea download follows object-storage redirects without leaking the token', async () => {
const requests = [];
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async (url, options) => {
requests.push({ url, options });
if (url.endsWith('/assets')) return Response.json([{ id: 2, name: 'manifest', browser_download_url: 'https://git.example.test/attachments/uuid' }]);
if (url.includes('/attachments/')) return new Response(null, { status: 302, headers: { location: 'https://objects.example.test/signed-file' } });
return new Response('file bytes');
});
assert.equal((await api.asset({ id: 1 }, 'manifest')).toString(), 'file bytes');
assert.equal(requests[1].options.headers.Authorization, 'token secret');
assert.deepEqual(requests[2].options.headers, {});
});
test('Gitea upload retries compare existing bytes and refuse conflicting assets', async () => {
const api = new Gitea('https://git.example.test/owner/repo', 'secret');
api.request = async () => [{ id: 1, name: 'manifest' }];
api.asset = async () => Buffer.from('original');
await api.upload({ id: 1 }, 'manifest', Buffer.from('original'));
await assert.rejects(api.upload({ id: 1 }, 'manifest', Buffer.from('changed')), /anderem Inhalt/);
});

View File

@@ -2,7 +2,12 @@
set -euo pipefail set -euo pipefail
FEDEO_REF="${FEDEO_REF:-dev}" FEDEO_REF="${FEDEO_REF:-dev}"
FEDEO_RAW_BASE_URL="${FEDEO_RAW_BASE_URL:-https://git.federspiel.tech/flfeders/FEDEO/raw/branch/${FEDEO_REF}}" FEDEO_REF_TYPE=branch
if [[ "$FEDEO_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
FEDEO_REF_TYPE=tag
export FEDEO_VERSION="${FEDEO_VERSION:-${FEDEO_REF#v}}"
fi
FEDEO_RAW_BASE_URL="${FEDEO_RAW_BASE_URL:-https://git.federspiel.tech/flfeders/FEDEO/raw/${FEDEO_REF_TYPE}/${FEDEO_REF}}"
FEDEO_DIR="${FEDEO_DIR:-/opt/fedeo}" FEDEO_DIR="${FEDEO_DIR:-/opt/fedeo}"
SETUP_ARGS=("$@") SETUP_ARGS=("$@")
FEDEO_USE_SUDO_DOCKER="${FEDEO_USE_SUDO_DOCKER:-false}" FEDEO_USE_SUDO_DOCKER="${FEDEO_USE_SUDO_DOCKER:-false}"
@@ -34,6 +39,7 @@ Mit Optionen für den Setup-Assistenten:
Umgebung: Umgebung:
FEDEO_DIR=/opt/fedeo FEDEO_DIR=/opt/fedeo
FEDEO_REF=dev FEDEO_REF=dev
FEDEO_REF=v1.0.0 # lädt Dateien vom unveränderlichen Release-Tag und pinnt die Images
FEDEO_RAW_BASE_URL=https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev FEDEO_RAW_BASE_URL=https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev
USAGE USAGE
} }

View File

@@ -321,6 +321,7 @@ write_env() {
cat >"$ENV_FILE" <<EOF cat >"$ENV_FILE" <<EOF
# FEDEO Selfhosting # FEDEO Selfhosting
FEDEO_VERSION=$(env_quote "${FEDEO_VERSION:-dev}")
DOMAIN=$(env_quote "$domain") DOMAIN=$(env_quote "$domain")
CONTACT_EMAIL=$(env_quote "$contact_email") CONTACT_EMAIL=$(env_quote "$contact_email")

View File

@@ -1,13 +1,21 @@
FROM node:20-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app/website WORKDIR /app/website
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . ./ COPY . ./
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
RUN npm run build RUN npm run build
FROM node:20-alpine AS runner FROM node:22-alpine AS runner
ARG FEDEO_VERSION=0.0.0-dev
ARG FEDEO_REVISION=unknown
ENV FEDEO_VERSION=$FEDEO_VERSION
ENV FEDEO_REVISION=$FEDEO_REVISION
WORKDIR /app/website WORKDIR /app/website
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_HOST=0.0.0.0 ENV NUXT_HOST=0.0.0.0