From ff598119ecf8455118d5cddd8c2d570cff4c4cea Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 25 Oct 2026 13:29:26 +0100 Subject: [PATCH] Releases in Gitea anlegen npm run release legt zu jedem Etikett v* ohne Release eines an. Der Text ist der Abschnitt der Fassung aus CHANGELOG.md, das Aenderungsprotokoll bleibt also die eine Quelle. Zugang ueber GITEA_TOKEN oder die Zugangsdaten, mit denen Git pusht; sie werden nie ausgegeben. Vorhandene Releases bleiben unangetastet, --trocken zeigt nur an. Die Releases fuer 1.0.0 bis 1.1.1 sind damit nachgetragen. Der Ablauf im Aenderungsprotokoll nennt den Schritt jetzt nach dem Push. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++- package.json | 3 +- scripts/release.mjs | 145 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 scripts/release.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 020046f..ce4bd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,15 +17,26 @@ Eine Fassung wird so festgeschrieben: ```bash npm version minor -m "Fassung %s" # hebt package.json und setzt ein Git-Etikett git push --follow-tags +npm run release # legt in Gitea das Release zum Etikett an ``` Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue -Überschrift mit Nummer und Datum. +Überschrift mit Nummer und Datum. Genau dieser Abschnitt wird der Text des +Releases – das Änderungsprotokoll bleibt die eine Quelle. `npm run release` +legt nur an, was fehlt, und lässt sich gefahrlos wiederholen; mit +`-- --trocken` zeigt es nur an, was es täte. --- ## [Unveröffentlicht] +### Hinzugefügt + +- **Releases in Gitea**, nicht nur Etiketten: `npm run release` legt zu jedem + Etikett `v*` ohne Release eines an, mit dem Abschnitt der Fassung aus diesem + Änderungsprotokoll als Text. Zugang über `GITEA_TOKEN` oder die Zugangsdaten, + mit denen Git pusht. Die Releases für 1.0.0 bis 1.1.1 sind nachgetragen + ## [1.1.1] – 2026-10-08 ### Hinzugefügt diff --git a/package.json b/package.json index 2249767..10923a9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "screenshots": "tsx scripts/screenshots.ts", "symbole": "tsx scripts/symbole.ts", "abnahme": "node scripts/pruefungen/abnahme.mjs", - "abnahme:container": "node scripts/pruefungen/container.mjs" + "abnahme:container": "node scripts/pruefungen/container.mjs", + "release": "node scripts/release.mjs" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..667e9f4 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,145 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; + +/** + * Legt zu Git-Etiketten die passenden Releases in Gitea an. + * + * node scripts/release.mjs alle Etiketten v*, denen noch ein Release fehlt + * node scripts/release.mjs v1.1.1 nur dieses + * node scripts/release.mjs --trocken nur anzeigen, was angelegt würde + * + * Der Text des Releases ist der Abschnitt der Fassung aus CHANGELOG.md – das + * Änderungsprotokoll bleibt die eine Quelle, das Release zeigt es nur an. + * + * Zugang: GITEA_TOKEN, falls gesetzt, sonst dieselben Zugangsdaten, mit denen + * Git pusht (`git credential fill`). Sie werden nie ausgegeben. + * + * Bereits vorhandene Releases bleiben unangetastet – der Lauf lässt sich also + * beliebig oft wiederholen. + */ + +const argumente = process.argv.slice(2); +const trocken = argumente.includes('--trocken'); +const gewuenscht = argumente.filter((a) => !a.startsWith('--')); + +function git(...befehl) { + const ergebnis = spawnSync('git', befehl, { encoding: 'utf8' }); + if (ergebnis.status !== 0) throw new Error(`git ${befehl.join(' ')}: ${ergebnis.stderr.trim()}`); + return ergebnis.stdout.trim(); +} + +// --- Wohin? ------------------------------------------------------------------ + +const remote = git('remote', 'get-url', 'origin'); +const treffer = remote.match(/^(?:https?:\/\/(?:[^@/]+@)?([^/]+)|[^@]+@([^:]+):)\/?(.+?)\/(.+?)(?:\.git)?$/); +if (!treffer) { + console.error(`Die Adresse von origin verstehe ich nicht: ${remote}`); + process.exit(2); +} +const host = treffer[1] ?? treffer[2]; +const [besitzer, repo] = [treffer[3], treffer[4]]; +const API = `https://${host}/api/v1/repos/${besitzer}/${repo}`; + +// --- Womit? ------------------------------------------------------------------ + +function anmeldung() { + if (process.env.GITEA_TOKEN) return `token ${process.env.GITEA_TOKEN}`; + const ergebnis = spawnSync('git', ['credential', 'fill'], { + input: `protocol=https\nhost=${host}\n\n`, + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + const felder = Object.fromEntries( + ergebnis.stdout + .split('\n') + .filter((z) => z.includes('=')) + .map((z) => [z.slice(0, z.indexOf('=')), z.slice(z.indexOf('=') + 1)]), + ); + if (!felder.username || !felder.password) { + console.error('Keine Zugangsdaten gefunden. GITEA_TOKEN setzen oder einmal von Hand pushen.'); + process.exit(2); + } + return `Basic ${Buffer.from(`${felder.username}:${felder.password}`).toString('base64')}`; +} + +// --- Was? -------------------------------------------------------------------- + +/** Die Abschnitte des Änderungsprotokolls, je Fassung. */ +function abschnitte() { + const text = fs.readFileSync('CHANGELOG.md', 'utf8'); + const teile = new Map(); + const kopf = /^## \[(\d+\.\d+\.\d+)\] – (\d{4}-\d{2}-\d{2})\s*$/gm; + const funde = [...text.matchAll(kopf)]; + funde.forEach((fund, i) => { + const ende = i + 1 < funde.length ? funde[i + 1].index : text.length; + const rumpf = text + .slice(fund.index + fund[0].length, ende) + .replace(/\n---\s*$/, '') + .trim(); + teile.set(fund[1], { datum: fund[2], rumpf }); + }); + return teile; +} + +const etiketten = git('tag', '--list', 'v*', '--sort=v:refname').split('\n').filter(Boolean); +const ziele = gewuenscht.length > 0 ? gewuenscht : etiketten; +const texte = abschnitte(); + +async function anfrage(pfad, art = 'GET', koerper) { + const antwort = await fetch(`${API}${pfad}`, { + method: art, + headers: { Authorization: anmeldung(), 'Content-Type': 'application/json', Accept: 'application/json' }, + body: koerper ? JSON.stringify(koerper) : undefined, + }); + return antwort; +} + +let fehler = 0; +for (const etikett of ziele) { + if (!etiketten.includes(etikett)) { + console.error(`✗ ${etikett}: Dieses Etikett gibt es nicht.`); + fehler += 1; + continue; + } + const fassung = etikett.replace(/^v/, ''); + const abschnitt = texte.get(fassung); + if (!abschnitt) { + console.error(`✗ ${etikett}: Im Änderungsprotokoll fehlt der Abschnitt [${fassung}].`); + fehler += 1; + continue; + } + + const vorhanden = await anfrage(`/releases/tags/${etikett}`); + if (vorhanden.status === 200) { + console.log(`· ${etikett}: Release gibt es schon.`); + continue; + } + if (vorhanden.status !== 404) { + console.error(`✗ ${etikett}: Gitea antwortet ${vorhanden.status} auf die Abfrage.`); + fehler += 1; + continue; + } + + const release = { + tag_name: etikett, + name: `Fassung ${fassung} – ${abschnitt.datum}`, + body: abschnitt.rumpf, + draft: false, + prerelease: false, + }; + + if (trocken) { + console.log(`○ ${etikett}: würde angelegt als „${release.name}" (${release.body.length} Zeichen Text).`); + continue; + } + + const angelegt = await anfrage('/releases', 'POST', release); + if (angelegt.status === 201) { + console.log(`✓ ${etikett}: Release angelegt.`); + } else { + console.error(`✗ ${etikett}: Gitea antwortet ${angelegt.status} – ${(await angelegt.text()).slice(0, 200)}`); + fehler += 1; + } +} + +process.exit(fehler === 0 ? 0 : 1);