3.0.0 #220
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
**/node_modules
|
||||
**/.nuxt
|
||||
**/.output
|
||||
**/dist
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
.release
|
||||
gmail-puett
|
||||
@@ -1,4 +1,6 @@
|
||||
# FEDEO Selfhosting
|
||||
# Feste Release-Version, z. B. 1.0.0. dev behält den bisherigen Entwicklungskanal bei.
|
||||
FEDEO_VERSION=dev
|
||||
DOMAIN=app.example.com
|
||||
CONTACT_EMAIL=admin@deine-domain.de
|
||||
|
||||
|
||||
@@ -1,233 +1,53 @@
|
||||
name: Build and Push Docker Images
|
||||
run-name: Build Backend, Frontend, Website & Docs by @${{ github.actor }}
|
||||
name: Build and Release FEDEO
|
||||
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
# Passe dies an deine Registry an.
|
||||
# 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
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# verify-docs-sync:
|
||||
# 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
|
||||
verify:
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
# Gitea stellt secrets.GITHUB_TOKEN automatisch bereit (wie GitLab CI_JOB_TOKEN)
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v4
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/backend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
node-version: '22'
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- name: Test release planning and recovery
|
||||
run: npm test
|
||||
- name: Check Conventional Commits
|
||||
run: npm run commits:check
|
||||
|
||||
- name: Build and push Backend
|
||||
uses: docker/build-push-action@v4
|
||||
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
|
||||
build-and-release:
|
||||
needs: verify
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v4
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
# Prefer repository secrets; retain the existing variable during migration.
|
||||
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/frontend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Frontend
|
||||
uses: docker/build-push-action@v4
|
||||
node-version: '22'
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
build-docs:
|
||||
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 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 }}
|
||||
registry: git.federspiel.tech
|
||||
username: flfeders
|
||||
password: ${{ secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
- name: Build all images and publish main releases
|
||||
# Gitea 1.25 ignores concurrency. ci.mjs uses an atomic Git ref lock instead.
|
||||
env:
|
||||
CI: 'true'
|
||||
FEDEO_REF: ${{ github.ref }}
|
||||
FEDEO_SHA: ${{ github.sha }}
|
||||
FEDEO_EVENT_NAME: ${{ github.event_name }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
run: npm run release:ci
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
/.release/
|
||||
|
||||
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||
matrix/postgres/
|
||||
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal 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
11
CHANGELOG.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Der Stack besteht aus:
|
||||
|
||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim
|
||||
FROM node:22-bookworm-slim
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -13,15 +13,18 @@ RUN apt-get update \
|
||||
COPY package*.json ./
|
||||
|
||||
# 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.
|
||||
RUN npm install --include=optional \
|
||||
&& npm install --include=optional --os=linux --cpu=x64 sharp
|
||||
# Optionale native Linux-Pakete werden aus dem Lockfile installiert.
|
||||
RUN npm ci --include=optional
|
||||
|
||||
# Restlicher Sourcecode
|
||||
COPY . .
|
||||
|
||||
# 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
|
||||
EXPOSE 3100
|
||||
|
||||
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -5794,6 +5794,44 @@
|
||||
"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": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@infisical/sdk/-/sdk-4.0.6.tgz",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"
|
||||
|
||||
import { centralServicesClient } from "./push-server.client"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { getBuildInfo } from "../utils/buildInfo"
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -31,7 +32,7 @@ export function startCentralServicesHeartbeat(server: FastifyInstance) {
|
||||
const sendHeartbeat = async () => {
|
||||
try {
|
||||
const response = await centralServicesClient.heartbeat({
|
||||
fedeoVersion: secrets.FEDEO_VERSION || "selfhost",
|
||||
fedeoVersion: getBuildInfo().version,
|
||||
baseUrl: instanceBaseUrl(),
|
||||
})
|
||||
console.log(`✅ Central Services Heartbeat: ${response.instanceId} (${response.status})`)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { getBuildInfo } from "../utils/buildInfo";
|
||||
|
||||
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 () => {
|
||||
// Testquery gegen DB
|
||||
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]),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
7
backend/src/utils/buildInfo.ts
Normal file
7
backend/src/utils/buildInfo.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
25
backend/tests/buildInfo.test.ts
Normal file
25
backend/tests/buildInfo.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
|
||||
container_name: fedeo-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
- internal
|
||||
|
||||
frontend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
|
||||
container_name: fedeo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
frontend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
|
||||
image: git.federspiel.tech/flfeders/fedeo/frontend:${FEDEO_VERSION:-dev}
|
||||
restart: always
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||
|
||||
docs:
|
||||
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
||||
image: git.federspiel.tech/flfeders/fedeo/docs:${FEDEO_VERSION:-dev}
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
|
||||
restart: always
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app/docs-site
|
||||
|
||||
COPY docs-site/package.json docs-site/package-lock.json ./
|
||||
@@ -7,9 +7,17 @@ RUN npm ci
|
||||
COPY docs-site ./
|
||||
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
|
||||
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
||||
140
docs/releases.md
Normal file
140
docs/releases.md
Normal 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
8
frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
@@ -1,18 +1,26 @@
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /usr/src/nuxt-app
|
||||
|
||||
# Nur Files kopieren, die für die Installation nötig sind (besseres Caching)
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# Restlichen Code kopieren und bauen
|
||||
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
|
||||
|
||||
# --- 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
|
||||
|
||||
@@ -28,4 +36,4 @@ ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||
|
||||
@@ -12,6 +12,7 @@ const colorMode = useColorMode()
|
||||
const {isHelpSlideoverOpen} = useDashboard()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const buildInfo = useRuntimeConfig().public
|
||||
|
||||
const auth = useAuthStore()
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
@@ -298,6 +299,9 @@ onMounted(() => {
|
||||
</UButton>
|
||||
</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"/>
|
||||
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,8 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
apiBase: '',
|
||||
pdfLicense: '',
|
||||
fedeoVersion: process.env.FEDEO_VERSION || '0.0.0-dev',
|
||||
fedeoRevision: process.env.FEDEO_REVISION || 'unknown',
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",
|
||||
|
||||
6225
package-lock.json
generated
Normal file
6225
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/admin/package.json apps/admin/package.json
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN npm run build --workspace @fedeo/push-admin
|
||||
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/apps/admin/.output ./.output
|
||||
|
||||
@@ -3,13 +3,17 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN npm run build --workspace @fedeo/push-db && npm run build --workspace @fedeo/push-api
|
||||
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
ENV DRIZZLE_MIGRATIONS_FOLDER=/app/packages/db/drizzle
|
||||
|
||||
15
release.config.json
Normal file
15
release.config.json
Normal 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
112
scripts/release/ci.mjs
Normal 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
21
scripts/release/cli.mjs
Normal 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
68
scripts/release/gitea.mjs
Normal 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
93
scripts/release/lib.mjs
Normal 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
25
scripts/release/lock.mjs
Normal 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.`);
|
||||
}
|
||||
86
scripts/release/publish.mjs
Normal file
86
scripts/release/publish.mjs
Normal 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 };
|
||||
}
|
||||
128
scripts/release/tests/planning.test.mjs
Normal file
128
scripts/release/tests/planning.test.mjs
Normal 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]), '');
|
||||
});
|
||||
166
scripts/release/tests/publication.test.mjs
Normal file
166
scripts/release/tests/publication.test.mjs
Normal 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/);
|
||||
});
|
||||
@@ -2,7 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
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}"
|
||||
SETUP_ARGS=("$@")
|
||||
FEDEO_USE_SUDO_DOCKER="${FEDEO_USE_SUDO_DOCKER:-false}"
|
||||
@@ -34,6 +39,7 @@ Mit Optionen für den Setup-Assistenten:
|
||||
Umgebung:
|
||||
FEDEO_DIR=/opt/fedeo
|
||||
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
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ write_env() {
|
||||
|
||||
cat >"$ENV_FILE" <<EOF
|
||||
# FEDEO Selfhosting
|
||||
FEDEO_VERSION=$(env_quote "${FEDEO_VERSION:-dev}")
|
||||
DOMAIN=$(env_quote "$domain")
|
||||
CONTACT_EMAIL=$(env_quote "$contact_email")
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app/website
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_HOST=0.0.0.0
|
||||
|
||||
Reference in New Issue
Block a user