From f7dc2cf7cba083df8cb07685820be3780271ba5d Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:21:23 +0200 Subject: [PATCH 1/2] fix(release): promote the hand-written Unreleased block instead of stranding it --- .github/workflows/release.yml | 41 ++------ CHANGELOG.md | 7 ++ scripts/changelog-release.js | 97 +++++++++++++++++++ tests/changelog_release.test.js | 164 ++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 34 deletions(-) create mode 100755 scripts/changelog-release.js create mode 100644 tests/changelog_release.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2de18b9..449821e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,42 +115,15 @@ jobs: if: steps.check_tag.outputs.exists == 'false' env: NEW_VERSION: ${{ steps.bump.outputs.new }} - BUMP_TYPE: ${{ steps.bump.outputs.bump }} COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - FIRST_LINE=$(echo "$COMMIT_MSG" | head -1) - DATE=$(date +%Y-%m-%d) - - if echo "$FIRST_LINE" | grep -qE "^feat"; then - SECTION="Features" - ENTRY=$(echo "$FIRST_LINE" | sed 's/^feat[^:]*: //') - elif echo "$FIRST_LINE" | grep -qE "^fix"; then - SECTION="Fixes" - ENTRY=$(echo "$FIRST_LINE" | sed 's/^fix[^:]*: //') - elif echo "$FIRST_LINE" | grep -qE "^docs"; then - SECTION="Dokumentation" - ENTRY=$(echo "$FIRST_LINE" | sed 's/^docs[^:]*: //') - else - SECTION="Änderungen" - ENTRY=$(echo "$FIRST_LINE" | sed 's/^[a-z]*[^:]*: //') - fi - - NEW_BLOCK="## [$NEW_VERSION] — $DATE - - ### $SECTION - - $ENTRY" - - # Remove leading whitespace from heredoc - NEW_BLOCK=$(echo "$NEW_BLOCK" | sed 's/^ //') - - if [ -f CHANGELOG.md ]; then - TEMP=$(mktemp) - awk -v block="$NEW_BLOCK" ' - /^# Changelog/ { print; print ""; print block; print ""; print "---"; next } - { print } - ' CHANGELOG.md > "$TEMP" - mv "$TEMP" CHANGELOG.md - fi + # Logik in scripts/changelog-release.js, damit sie testbar ist + # (tests/changelog_release.test.js). Der frühere awk-Einzeiler schob + # den erzeugten Block blind hinter "# Changelog" und ließ einen + # handgepflegten [Unreleased]-Abschnitt darunter liegen — dessen + # Beschreibungen erschienen dadurch in keinem Release. + node scripts/changelog-release.js \ + "$NEW_VERSION" "$(date +%Y-%m-%d)" "$(echo "$COMMIT_MSG" | head -1)" - name: Commit version bump if: steps.check_tag.outputs.exists == 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0d6270..10084ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Fixed +- Der Release-Workflow übernahm den handgeschriebenen `[Unreleased]`-Abschnitt nicht: er schob seinen aus der letzten Commit-Message erzeugten Versionsblock blind hinter die Kopfzeile und ließ den vorhandenen Abschnitt darunter liegen. Ausführliche Beschreibungen landeten dadurch in keinem Release, und die Datei sammelte über 24 Releases hinweg `[Unreleased]`-Überschriften an, die längst ausgeliefert waren. Der Abschnitt wird jetzt zur Version befördert; die Commit-Zeile dient nur noch als Rückfall, wenn niemand etwas geschrieben hat. + Die Logik liegt dafür in `scripts/changelog-release.js` statt als awk-Einzeiler im Workflow — sie ist damit erstmals durch Tests abgedeckt. + **Hinweis:** Die 24 bereits verwaisten Blöcke bleiben unangetastet. Welche Version sie ausgeliefert hat, lässt sich nicht zuverlässig rekonstruieren; eine geratene Zuordnung würde den Verlauf unwahr machen. + ## [1.118.8] — 2026-07-26 ### Fixes diff --git a/scripts/changelog-release.js b/scripts/changelog-release.js new file mode 100755 index 00000000..f0e68115 --- /dev/null +++ b/scripts/changelog-release.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Prepare CHANGELOG.md for a release. + * + * Wird vom Release-Workflow aufgerufen. Die Logik lag früher als awk-Einzeiler + * im YAML und war damit nicht testbar — sie hatte einen Fehler, der sich über + * 24 Releases angesammelt hat: + * + * awk '/^# Changelog/ { print; print ""; print block; … }' + * + * Der erzeugte Versionsblock wurde blind hinter die Überschrift geschoben. Ein + * von Hand gepflegter `## [Unreleased]`-Abschnitt blieb dabei liegen, wo er + * war — also unterhalb der neuen Version. Folge: die ausführlichen + * Beschreibungen erschienen in keinem Release, und die Datei sammelte + * `[Unreleased]`-Überschriften an, die längst ausgeliefert waren. + * + * Neues Verhalten: + * - Steht direkt unter `# Changelog` ein `## [Unreleased]` MIT Inhalt, wird + * dessen Überschrift zur Version. Der aus der Commit-Message erzeugte + * Eintrag entfällt dann: hat jemand die Änderung beschrieben, ist die + * erste Zeile des letzten Commits die schlechtere Zusammenfassung. + * - Sonst wird wie bisher ein Block aus der Commit-Message erzeugt. + * + * Aufruf: node scripts/changelog-release.js [datei] + */ + +const fs = require('node:fs'); + +function sectionFor(subject) { + if (/^feat/.test(subject)) return 'Features'; + if (/^fix/.test(subject)) return 'Fixes'; + if (/^docs/.test(subject)) return 'Dokumentation'; + return 'Änderungen'; +} + +function entryFor(subject) { + // "feat(scope): text" → "text"; ohne Präfix bleibt die Zeile, wie sie ist. + const m = subject.match(/^[a-z]+(\([^)]*\))?!?:\s*(.*)$/); + return m ? m[2] : subject; +} + +/** + * @returns {{ text: string, promoted: boolean }} promoted=true, wenn ein + * handgepflegter Unreleased-Block zur Version wurde. + */ +function prepare(content, version, date, subject) { + const lines = content.split('\n'); + const headingAt = lines.findIndex((l) => /^## \[/.test(l)); + const headerEnd = headingAt === -1 ? lines.length : headingAt; + + const isUnreleased = headingAt !== -1 && /^## \[Unreleased\]/i.test(lines[headingAt]); + if (isUnreleased) { + // Inhalt bis zur nächsten Überschrift — Trennstriche und Leerzeilen zählen nicht. + const next = lines.findIndex((l, i) => i > headingAt && /^## \[/.test(l)); + const end = next === -1 ? lines.length : next; + const hasContent = lines.slice(headingAt + 1, end) + .some((l) => l.trim() && l.trim() !== '---'); + if (hasContent) { + const out = lines.slice(); + out[headingAt] = `## [${version}] — ${date}`; + return { text: out.join('\n'), promoted: true }; + } + } + + const block = [ + `## [${version}] — ${date}`, + '', + `### ${sectionFor(subject)}`, + `- ${entryFor(subject)}`, + '', + '---', + '', + ]; + const out = lines.slice(0, headerEnd).concat(block, lines.slice(headerEnd)); + return { text: out.join('\n'), promoted: false }; +} + +module.exports = { prepare, sectionFor, entryFor }; + +if (require.main === module) { + const [version, date, subject, file = 'CHANGELOG.md'] = process.argv.slice(2); + if (!version || !date || subject === undefined) { + console.error('usage: changelog-release.js [file]'); + process.exit(2); + } + if (!fs.existsSync(file)) { + console.error(`${file} not found — nothing to do`); + process.exit(0); + } + const { text, promoted } = prepare(fs.readFileSync(file, 'utf8'), version, date, subject.split('\n')[0]); + fs.writeFileSync(file, text); + console.log(promoted + ? `promoted the hand-written [Unreleased] block to [${version}]` + : `inserted a generated block for [${version}]`); +} diff --git a/tests/changelog_release.test.js b/tests/changelog_release.test.js new file mode 100644 index 00000000..3c53846e --- /dev/null +++ b/tests/changelog_release.test.js @@ -0,0 +1,164 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { prepare } = require('../scripts/changelog-release'); + +// Regression gegen den Fehler, der sich über 24 Releases angesammelt hat: der +// Release-Workflow schob seinen erzeugten Block blind hinter "# Changelog" und +// ließ einen handgepflegten [Unreleased]-Abschnitt darunter liegen. Dessen +// Beschreibungen erschienen dadurch in keinem Release, und die Datei sammelte +// [Unreleased]-Überschriften an, die längst ausgeliefert waren. + +const WITH_UNRELEASED = `# Changelog + +## [Unreleased] + +### Security +- Etwas Wichtiges, von Hand beschrieben. + +### Fixed +- Noch etwas. + +--- + +## [1.0.0] — 2026-01-01 + +### Fixes +- alt + +--- +`; + +const WITHOUT_UNRELEASED = `# Changelog + +## [1.0.0] — 2026-01-01 + +### Fixes +- alt + +--- +`; + +const EMPTY_UNRELEASED = `# Changelog + +## [Unreleased] + +--- + +## [1.0.0] — 2026-01-01 + +### Fixes +- alt + +--- +`; + +function headings(text) { + return text.split('\n').filter((l) => l.startsWith('## [')); +} + +test('a hand-written Unreleased block becomes the release', () => { + const { text, promoted } = prepare(WITH_UNRELEASED, '1.1.0', '2026-02-02', 'fix: irgendwas'); + assert.equal(promoted, true); + assert.deepEqual(headings(text), ['## [1.1.0] — 2026-02-02', '## [1.0.0] — 2026-01-01']); + // Der Inhalt reist mit — das ist der ganze Punkt. + assert.match(text, /## \[1\.1\.0\] — 2026-02-02\n\n### Security\n- Etwas Wichtiges/); + assert.match(text, /- Noch etwas\./); + // Kein zurückgelassener Unreleased-Block mehr. + assert.equal((text.match(/## \[Unreleased\]/g) || []).length, 0); +}); + +test('the commit-derived entry is dropped when a human already described the release', () => { + const { text } = prepare(WITH_UNRELEASED, '1.1.0', '2026-02-02', 'fix: irgendwas'); + assert.ok(!text.includes('irgendwas'), 'die Commit-Zeile verdrängt die Beschreibung'); +}); + +test('without an Unreleased block the generated entry is inserted as before', () => { + const { text, promoted } = prepare(WITHOUT_UNRELEASED, '1.1.0', '2026-02-02', 'feat(scope): neue Sache'); + assert.equal(promoted, false); + assert.deepEqual(headings(text), ['## [1.1.0] — 2026-02-02', '## [1.0.0] — 2026-01-01']); + assert.match(text, /### Features\n- neue Sache/); +}); + +test('an empty Unreleased block falls back to the generated entry', () => { + // Sonst entstünde ein Versionsblock ohne jeden Inhalt. + const { text, promoted } = prepare(EMPTY_UNRELEASED, '1.1.0', '2026-02-02', 'fix: etwas'); + assert.equal(promoted, false); + assert.match(text, /### Fixes\n- etwas/); +}); + +test('the commit type decides the section, and the prefix is stripped', () => { + for (const [subject, section, entry] of [ + ['feat: a', 'Features', 'a'], + ['feat(x): b', 'Features', 'b'], + ['fix!: c', 'Fixes', 'c'], + ['docs: d', 'Dokumentation', 'd'], + ['chore: e', 'Änderungen', 'e'], + ['ganz ohne Präfix', 'Änderungen', 'ganz ohne Präfix'], + ]) { + const { text } = prepare(WITHOUT_UNRELEASED, '1.1.0', '2026-02-02', subject); + assert.match(text, new RegExp(`### ${section}\\n- ${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), subject); + } +}); + +test('only the topmost block is promoted — stray Unreleased headings stay untouched', () => { + // Die 24 Altlasten in der echten Datei dürfen nicht versehentlich zur Version werden. + const stray = `# Changelog + +## [1.0.0] — 2026-01-01 + +### Fixes +- alt + +--- + +## [Unreleased] + +### Fixed +- eine Altlast weiter unten + +--- +`; + const { text, promoted } = prepare(stray, '1.1.0', '2026-02-02', 'fix: etwas'); + assert.equal(promoted, false); + assert.equal((text.match(/## \[Unreleased\]/g) || []).length, 1, 'die Altlast wurde angefasst'); + assert.deepEqual(headings(text)[0], '## [1.1.0] — 2026-02-02'); +}); + +// Beide Fälle werden aus der echten Datei ABGELEITET, statt ihren jeweiligen +// Zustand vorauszusetzen — sonst kippt der Test, sobald jemand einen +// Unreleased-Abschnitt anlegt oder ein Release ihn befördert. +function realChangelog() { + const fs = require('node:fs'); + const path = require('node:path'); + return fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8'); +} +function withoutTopUnreleased(text) { + const lines = text.split('\n'); + const h = lines.findIndex((l) => /^## \[/.test(l)); + if (h === -1 || !/^## \[Unreleased\]/i.test(lines[h])) return text; + const next = lines.findIndex((l, i) => i > h && /^## \[/.test(l)); + return lines.slice(0, h).concat(lines.slice(next === -1 ? lines.length : next)).join('\n'); +} + +test('the real CHANGELOG: without a top Unreleased block, exactly one heading is added', () => { + const real = withoutTopUnreleased(realChangelog()); + const { text, promoted } = prepare(real, '9.9.9', '2026-12-31', 'fix: probe'); + assert.equal(promoted, false); + assert.equal(headings(text).length, headings(real).length + 1); + assert.equal(headings(text)[0], '## [9.9.9] — 2026-12-31'); + assert.ok(text.startsWith('# Changelog')); + // Kein Bestandsinhalt verloren: die alte Datei steckt vollständig in der neuen. + assert.ok(text.includes(real.slice(real.indexOf('## [')))); +}); + +test('the real CHANGELOG: with a hand-written block on top, it is promoted and nothing is added', () => { + const base = withoutTopUnreleased(realChangelog()); + const withBlock = base.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n### Security\n- von Hand\n\n---\n'); + const { text, promoted } = prepare(withBlock, '9.9.9', '2026-12-31', 'fix: probe'); + assert.equal(promoted, true); + assert.equal(headings(text).length, headings(withBlock).length, 'es kam eine Überschrift dazu'); + assert.equal(headings(text)[0], '## [9.9.9] — 2026-12-31'); + assert.match(text, /## \[9\.9\.9\] — 2026-12-31\n\n### Security\n- von Hand/); + assert.ok(!text.includes('- probe'), 'die Commit-Zeile wurde zusätzlich eingefügt'); +}); From a49948da4795f93a7cbac30cb6bbdbdf75c4c071 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:35:32 +0200 Subject: [PATCH 2/2] fix(release): read the changelog directly instead of check-then-use --- scripts/changelog-release.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/changelog-release.js b/scripts/changelog-release.js index f0e68115..85ac7dea 100755 --- a/scripts/changelog-release.js +++ b/scripts/changelog-release.js @@ -85,11 +85,20 @@ if (require.main === module) { console.error('usage: changelog-release.js [file]'); process.exit(2); } - if (!fs.existsSync(file)) { - console.error(`${file} not found — nothing to do`); - process.exit(0); + // Lesen und den Fehlerfall abfangen, statt vorher auf Existenz zu prüfen: + // existsSync + readFileSync ist Check-then-Use (CodeQL js/file-system-race) + // — und der Versuch ist ohnehin die kürzere Fassung. + let current; + try { + current = fs.readFileSync(file, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') { + console.error(`${file} not found — nothing to do`); + process.exit(0); + } + throw e; } - const { text, promoted } = prepare(fs.readFileSync(file, 'utf8'), version, date, subject.split('\n')[0]); + const { text, promoted } = prepare(current, version, date, subject.split('\n')[0]); fs.writeFileSync(file, text); console.log(promoted ? `promoted the hand-written [Unreleased] block to [${version}]`