diff --git a/CHANGELOG.md b/CHANGELOG.md index 71bb084..a337647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### 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 Erste stabile Fassung. Kein neuer Funktionsumfang gegenüber 0.27.1 – was fehlte, war Erprobung, und diff --git a/DEVLOG.md b/DEVLOG.md index 4950394..e94eb6e 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -73,7 +73,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 1.0.0 +**Version:** 1.0.1 ### Erledigt - 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 - 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 - #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) diff --git a/README.md b/README.md index a13a3f9..bf12480 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,12 @@ npm run issues:sync -- --dry-run # zeigt nur, was passieren würde 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 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`. Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel, diff --git a/docs/issues/046-gitea-releases.md b/docs/issues/046-gitea-releases.md new file mode 100644 index 0000000..5efffb6 --- /dev/null +++ b/docs/issues/046-gitea-releases.md @@ -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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 97b3ac2..2ec0622 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | | [#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 | -| | **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** | diff --git a/package.json b/package.json index d9f6c20..a1753f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "1.0.0", + "version": "1.0.1", "private": true, "scripts": { "dev": "next dev", @@ -27,7 +27,8 @@ "screenshots": "node scripts/screenshots.mjs", "icons": "node scripts/icons.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": { "@node-rs/argon2": "^2.2.1", diff --git a/scripts/gitea-releases.ts b/scripts/gitea-releases.ts new file mode 100644 index 0000000..f335a6a --- /dev/null +++ b/scripts/gitea-releases.ts @@ -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 { + 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(pfad: string, optionen: RequestInit = {}): Promise { + 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("/releases?limit=100"); + } + + anlegen(daten: { tag_name: string; name: string; body: string }) { + return this.anfrage("/releases", { method: "POST", body: JSON.stringify(daten) }); + } + + aktualisieren(id: number, daten: { name: string; body: string }) { + return this.anfrage(`/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); +}); diff --git a/tests/gitea-releases.test.ts b/tests/gitea-releases.test.ts new file mode 100644 index 0000000..2760c56 --- /dev/null +++ b/tests/gitea-releases.test.ts @@ -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([]); + }); +});