Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 7 additions & 34 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
106 changes: 106 additions & 0 deletions scripts/changelog-release.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/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 <version> <date> <commit-subject> [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 <version> <date> <commit-subject> [file]');
process.exit(2);
}
// 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(current, version, date, subject.split('\n')[0]);
fs.writeFileSync(file, text);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
console.log(promoted
? `promoted the hand-written [Unreleased] block to [${version}]`
: `inserted a generated block for [${version}]`);
}
164 changes: 164 additions & 0 deletions tests/changelog_release.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading