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>
146 lines
5.0 KiB
JavaScript
146 lines
5.0 KiB
JavaScript
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);
|