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);