Merge branch 'phase/048-releases'

This commit is contained in:
2026-10-25 13:29:36 +01:00
8 changed files with 319 additions and 5 deletions
+5
View File
@@ -17,6 +17,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [1.0.1] – 2026-10-25
### Hinzugefügt
- `npm run releases:sync` legt zu jedem Tag eine Release in Gitea an – Titel und Beschreibung stammen aus diesem CHANGELOG (#046)
## [1.0.0] – 2026-10-23 ## [1.0.0] – 2026-10-23
Erste stabile Fassung. Kein neuer Funktionsumfang gegenüber 0.27.1 – was fehlte, war Erprobung, und Erste stabile Fassung. Kein neuer Funktionsumfang gegenüber 0.27.1 – was fehlte, war Erprobung, und
+5 -1
View File
@@ -73,7 +73,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 1.0.0 **Version:** 1.0.1
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -242,6 +242,10 @@
- PMG wird zurückgestellt – kein Host im Betrieb. Die Lücke bleibt im Handbuch benannt, statt sie wegzudefinieren - PMG wird zurückgestellt – kein Host im Betrieb. Die Lücke bleibt im Handbuch benannt, statt sie wegzudefinieren
- Damit ist 1.0.0 gerechtfertigt: nicht weil nichts mehr fehlt, sondern weil das, was fehlte, erprobt wurde - Damit ist 1.0.0 gerechtfertigt: nicht weil nichts mehr fehlt, sondern weil das, was fehlte, erprobt wurde
### 25.10.2026 – Releases
- `releases:sync` gebaut: Das CHANGELOG ist die Quelle, Gitea bekommt daraus Releases zu den vorhandenen Tags
- Dabei bestätigt, was beim Issue-Abgleich am 10.10. schon aufgefallen war: Der hinterlegte Zugang ist ein elfstelliges Passwort, kein API-Token. Pushen geht, die API nicht
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+4 -1
View File
@@ -175,9 +175,12 @@ npm run issues:sync -- --dry-run # zeigt nur, was passieren würde
npm run issues:sync # echter Abgleich npm run issues:sync # echter Abgleich
``` ```
Dasselbe gilt für Releases: `npm run releases:sync` legt zu jedem Tag einen Eintrag unter *Releases*
an und nimmt den passenden Abschnitt aus dem CHANGELOG als Beschreibung (`--dry-run` zeigt es nur an).
Serveradresse und Repository liest das Skript aus dem Git-Remote, den Token aus den hinterlegten Serveradresse und Repository liest das Skript aus dem Git-Remote, den Token aus den hinterlegten
Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`) – derselbe Token wie fürs Pushen, er braucht Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`) – derselbe Token wie fürs Pushen, er braucht
zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_URL`, `GITEA_REPO` und zusätzlich die Rechte `issue:write` (Issues) und `write:repository` (Releases). Wer beides trennen möchte, setzt `GITEA_URL`, `GITEA_REPO` und
`GITEA_TOKEN` in der `.env`. `GITEA_TOKEN` in der `.env`.
Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel, Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel,
+36
View File
@@ -0,0 +1,36 @@
# #046 Releases in Gitea statt nur Tags
**Status:** erledigt
**Meilenstein:** v1.0.1
**Labels:** chore, doku
**Geschätzt:** 1 h **Tatsächlich:** 0 h 25 min
## Auslöser
„Kannst du auch Releases machen und nicht nur Tags?“
## Unterschied
Ein Tag ist eine Markierung im Git-Verlauf. Eine **Release** ist ein Eintrag in Gitea mit Titel,
Beschreibung und Download-Verweisen – sichtbar unter *Releases*, abonnierbar und verlinkbar.
Bislang gab es nur Tags; die Beschreibungen standen allein im CHANGELOG.
## Umsetzung
`npm run releases:sync` liest das CHANGELOG, nimmt jeden Abschnitt `## [x.y.z] – Datum`, für den es
ein Tag gibt, und legt daraus eine Release an. Ein zweiter Lauf aktualisiert nur, was sich geändert
hat; „Unreleased“ bleibt außen vor, weil es dazu kein Tag gibt.
```bash
npm run releases:sync -- --dry-run # zeigt nur, was passieren würde
npm run releases:sync # legt an und aktualisiert
npm run releases:sync -- v1.0.0 # nur eine Fassung
```
Fünf Tests decken die Zerlegung ab.
## Offen: der Token
Der in den Git-Zugangsdaten hinterlegte Wert ist elf Zeichen lang – ein Passwort, kein API-Token.
Pushen funktioniert damit, die API antwortet mit 401. Betrifft auch `npm run issues:sync`, das seit
dem 10.10.2026 nicht mehr durchläuft.
**Nötig:** in Gitea unter *Einstellungen → Anwendungen* ein Token mit den Rechten
`write:repository` (für Releases) und `issue:write` (für den Issue-Abgleich) erzeugen und entweder
als `GITEA_TOKEN` in die `.env` legen oder in den Git-Zugangsdaten hinterlegen. Danach trägt ein
einziger Lauf alle 43 vorhandenen Fassungen nach.
+2 -1
View File
@@ -49,4 +49,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min | | [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min |
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min | | [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
| [#045](045-wiederherstellung-geprobt.md) | Wiederherstellung proben und Skript dafür | erledigt | v0.27.0 | 2 h | 0 h 50 min | | [#045](045-wiederherstellung-geprobt.md) | Wiederherstellung proben und Skript dafür | erledigt | v0.27.0 | 2 h | 0 h 50 min |
| | **Summe** | | | **177 h** | **25 h 28 min** | | [#046](046-gitea-releases.md) | Releases in Gitea statt nur Tags | erledigt | v1.0.1 | 1 h | 0 h 25 min |
| | **Summe** | | | **178 h** | **25 h 53 min** |
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "1.0.0", "version": "1.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -27,7 +27,8 @@
"screenshots": "node scripts/screenshots.mjs", "screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs", "icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs", "pruefe:doku": "node scripts/pruefe-doku.mjs",
"benutzer": "tsx scripts/benutzer.ts" "benutzer": "tsx scripts/benutzer.ts",
"releases:sync": "tsx scripts/gitea-releases.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
+210
View File
@@ -0,0 +1,210 @@
/**
* Legt zu jedem Git-Tag eine Release in Gitea an – mit dem passenden Abschnitt aus dem CHANGELOG
* als Beschreibung.
*
* npm run releases:sync -- --dry-run # zeigt nur, was passieren würde
* npm run releases:sync # legt an und aktualisiert
* npm run releases:sync -- v1.0.0 # nur diese eine Fassung
*
* Quelle der Wahrheit bleibt das CHANGELOG: Ein zweites Mal aufgerufen, wird eine vorhandene
* Release nur aktualisiert, wenn sich der Text geändert hat.
*
* Der Token braucht das Recht **write:repository** – `issue:write` allein genügt nicht. Er wird
* aus `GITEA_TOKEN` gelesen, sonst aus den Git-Zugangsdaten (#047).
*/
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import "dotenv/config";
interface Fassung {
tag: string;
/** Überschrift ohne Klammern, z. B. „1.0.0 – 2026-10-23“. */
titel: string;
text: string;
datum: string | null;
}
interface Release {
id: number;
tag_name: string;
name: string;
body: string;
}
/** Zerlegt das CHANGELOG in seine Abschnitte je Fassung. */
export function changelogZerlegen(inhalt: string): Fassung[] {
const zeilen = inhalt.split("\n");
const fassungen: Fassung[] = [];
let aktuell: Fassung | null = null;
for (const zeile of zeilen) {
// Überschriften der Form „## [1.0.0] – 2026-10-23“ (Keep a Changelog).
const kopf = /^##\s*\[([^\]]+)\]\s*[–-]?\s*(.*)$/.exec(zeile);
if (kopf) {
if (aktuell) fassungen.push(aktuell);
const nummer = kopf[1].trim();
const datum = kopf[2].trim() || null;
// „Unreleased“ hat kein Tag und gehört nicht als Release nach Gitea.
aktuell = /^\d/.test(nummer)
? { tag: `v${nummer}`, titel: datum ? `${nummer} – ${datum}` : nummer, text: "", datum }
: null;
continue;
}
if (aktuell) aktuell.text += `${zeile}\n`;
}
if (aktuell) fassungen.push(aktuell);
return fassungen.map((fassung) => ({ ...fassung, text: fassung.text.trim() }));
}
function vorhandeneTags(): Set<string> {
const ausgabe = execFileSync("git", ["tag", "--list", "v*"], { encoding: "utf8" });
return new Set(ausgabe.split("\n").map((zeile) => zeile.trim()).filter(Boolean));
}
function ausRemote(): { basis: string; repo: string } | null {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
if (!treffer) return null;
return { basis: `https://${treffer[1]}`, repo: treffer[2] };
} catch {
return null;
}
}
/** Wie beim Issue-Abgleich: ein Token für Push und API, kein zweiter Eintrag in der .env. */
function tokenAusGitCredentials(basis: string): string | null {
const host = new URL(basis).host;
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
try {
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
input: `protocol=https\nhost=${host}\n\n`,
encoding: "utf8",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
stdio: ["pipe", "pipe", "ignore"],
});
const passwort = /^password=(.*)$/m.exec(antwort);
if (passwort?.[1]) return passwort[1];
} catch {
// Nächste Möglichkeit versuchen.
}
}
return null;
}
class Gitea {
constructor(
private readonly basis: string,
private readonly repo: string,
private readonly token: string,
) {}
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
...optionen,
headers: {
Authorization: `token ${this.token}`,
"Content-Type": "application/json",
...optionen.headers,
},
});
if (!antwort.ok) {
const text = await antwort.text();
const hinweis =
antwort.status === 401 || antwort.status === 403
? "\n Der Token braucht das Recht „write:repository“. Gitea: Einstellungen → Anwendungen → Token erzeugen."
: "";
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${text}${hinweis}`);
}
// Beim Löschen kommt 204 ohne Inhalt zurück.
return antwort.status === 204 ? (undefined as T) : ((await antwort.json()) as T);
}
releases() {
return this.anfrage<Release[]>("/releases?limit=100");
}
anlegen(daten: { tag_name: string; name: string; body: string }) {
return this.anfrage<Release>("/releases", { method: "POST", body: JSON.stringify(daten) });
}
aktualisieren(id: number, daten: { name: string; body: string }) {
return this.anfrage<Release>(`/releases/${id}`, { method: "PATCH", body: JSON.stringify(daten) });
}
}
async function main() {
const trockenlauf = process.argv.includes("--dry-run");
const nurFassung = process.argv.slice(2).find((wert) => /^v?\d+\.\d+\.\d+$/.test(wert));
const remote = ausRemote();
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
if (!basis || !repo) {
console.error("Kein Gitea-Ziel gefunden. GITEA_URL und GITEA_REPO setzen oder ein Remote einrichten.");
process.exit(1);
}
const changelog = await readFile(path.join(process.cwd(), "CHANGELOG.md"), "utf8");
const tags = vorhandeneTags();
// Nur Fassungen, zu denen es auch ein Tag gibt – eine Release ohne Tag wäre eine Behauptung.
let fassungen = changelogZerlegen(changelog).filter((fassung) => tags.has(fassung.tag));
if (nurFassung) {
const gesucht = nurFassung.startsWith("v") ? nurFassung : `v${nurFassung}`;
fassungen = fassungen.filter((fassung) => fassung.tag === gesucht);
}
console.log(`Ziel: ${basis}/${repo}`);
console.log(`${fassungen.length} Fassung(en) mit Tag im CHANGELOG.`);
const token = process.env.GITEA_TOKEN ?? tokenAusGitCredentials(basis);
if (trockenlauf || !token) {
if (!token && !trockenlauf) {
console.log("\nKein Token gefunden – es folgt nur ein Trockenlauf.");
console.log("Ein Token mit dem Recht „write:repository“ gehört in GITEA_TOKEN.");
}
for (const fassung of fassungen) {
console.log(` ${fassung.tag}: „${fassung.titel}“ (${fassung.text.split("\n").length} Zeilen)`);
}
return;
}
const gitea = new Gitea(basis, repo, token);
const vorhanden = new Map((await gitea.releases()).map((release) => [release.tag_name, release]));
for (const fassung of fassungen) {
const alt = vorhanden.get(fassung.tag);
if (!alt) {
if (!trockenlauf) {
await gitea.anlegen({ tag_name: fassung.tag, name: fassung.titel, body: fassung.text });
}
console.log(`Angelegt: ${fassung.tag}`);
continue;
}
if (alt.body.trim() === fassung.text && alt.name === fassung.titel) {
console.log(`Unverändert: ${fassung.tag}`);
continue;
}
if (!trockenlauf) await gitea.aktualisieren(alt.id, { name: fassung.titel, body: fassung.text });
console.log(`Aktualisiert: ${fassung.tag}`);
}
console.log("\nAbgleich abgeschlossen.");
}
main().catch((fehler: unknown) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { changelogZerlegen } from "../scripts/gitea-releases";
/** Zerlegung des CHANGELOG in Release-Abschnitte (#046). */
const BEISPIEL = `# Changelog
Alle wesentlichen Änderungen.
## [Unreleased]
- noch nichts
## [1.0.0] – 2026-10-23
Erste stabile Fassung.
### Hinzugefügt
- Dies und das
## [0.27.1] – 2026-10-23
### Sicherheit
- Next.js angehoben
`;
describe("CHANGELOG zerlegen", () => {
it("findet die Fassungen mit Datum", () => {
const fassungen = changelogZerlegen(BEISPIEL);
expect(fassungen.map((f) => f.tag)).toEqual(["v1.0.0", "v0.27.1"]);
expect(fassungen[0].titel).toBe("1.0.0 – 2026-10-23");
});
it("übergeht „Unreleased“ – dazu gibt es kein Tag", () => {
expect(changelogZerlegen(BEISPIEL).some((f) => f.tag.includes("Unreleased"))).toBe(false);
});
it("nimmt den Text bis zur nächsten Überschrift mit", () => {
const [eins] = changelogZerlegen(BEISPIEL);
expect(eins.text).toContain("Erste stabile Fassung.");
expect(eins.text).toContain("### Hinzugefügt");
expect(eins.text).not.toContain("0.27.1");
});
it("schneidet Leerzeilen an den Rändern ab", () => {
const [eins] = changelogZerlegen(BEISPIEL);
expect(eins.text.startsWith("Erste")).toBe(true);
expect(eins.text.endsWith("Dies und das")).toBe(true);
});
it("kommt mit einem Changelog ohne Fassungen zurecht", () => {
expect(changelogZerlegen("# Changelog\n\nNoch nichts veröffentlicht.\n")).toEqual([]);
});
});