Files
lernbar/scripts/release.mjs
T
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

146 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);