Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 ff598119ec 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 <noreply@anthropic.com>
2026-10-25 13:29:26 +01:00
3 changed files with 159 additions and 2 deletions
+12 -1
View File
@@ -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
+2 -1
View File
@@ -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",
+145
View File
@@ -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);