From f5656e76c37cfac3fe5bdca23945f60d6ceeb491 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Mon, 14 Sep 2026 17:03:10 -0700 Subject: [PATCH 1/4] fix: stop doubling the changelog header newlines Every line of the [changelog] header template in cliff.toml ended with a literal \n escape on top of the newline a TOML multi-line string already contributes, so each line emitted two newlines. The rendered header carried three blank lines between the markdownlint configure comment and "# Changelog", which markdownlint rejects as MD012. Locally the pre-commit markdownlint --fix hook collapsed them before anything reached a commit, so the checked-in changelog looked fine. The changelog-autoupdate workflow only lints, so every scheduled run since at least 2026-06-29 failed on MD012 without ever opening a PR. Write the blank lines as blank lines. Verified against git-cliff 2.14.1, the version CI installs: regeneration now lints clean and is idempotent, and the generated header matches the committed changelog byte for byte. Add tests/test-cliff-header.sh, which decodes the template the way TOML does and asserts the MD012 invariant. It needs no git-cliff, which CI does not install, so a rendering test would have skipped there silently. Co-Authored-By: Claude Opus 5 (1M context) --- cliff.toml | 19 ++++++-- tests/test-cliff-header.sh | 97 ++++++++++++++++++++++++++++++++++++++ tests/test-unit.sh | 1 + 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100755 tests/test-cliff-header.sh diff --git a/cliff.toml b/cliff.toml index c5bd82b..5ea594b 100644 --- a/cliff.toml +++ b/cliff.toml @@ -8,12 +8,21 @@ breaking_always_bump_major = false # header, body, and footer keys are Tera templates # https://keats.github.io/tera/docs/#introduction +# +# Blank lines here are written as blank lines, never as trailing \n escapes: a +# TOML multi-line string already ends each source line with a newline, so an +# escape on top of that emits a second one. That doubling is invisible in the +# source and lands in the generated changelog as consecutive blank lines, which +# markdownlint rejects (MD012) in the changelog-autoupdate workflow. header = """ -\n -\n -# Changelog\n -All notable changes will be documented in this file. See [conventional commits](https://www.conventionalcommits.org) for commit guidelines.\n -The format is based on [Keep a Changelog](https://keepachangelog.com) and this project adheres to [Semantic Versioning](https://semver.org).\n + + +# Changelog + +All notable changes will be documented in this file. See [conventional commits](https://www.conventionalcommits.org) for commit guidelines. + +The format is based on [Keep a Changelog](https://keepachangelog.com) and this project adheres to [Semantic Versioning](https://semver.org). + """ body = """ diff --git a/tests/test-cliff-header.sh b/tests/test-cliff-header.sh new file mode 100755 index 0000000..e8f4d8e --- /dev/null +++ b/tests/test-cliff-header.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Test script for the [changelog] header template in cliff.toml + +set -uo pipefail +TEST_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Load shared configurations +# shellcheck disable=SC1091 # Dynamic path via $TEST_SCRIPT_DIR +. "$TEST_SCRIPT_DIR/colors.sh" + +CLIFF_TOML="$TEST_SCRIPT_DIR/../cliff.toml" + +PASSED=0 +FAILED=0 + +pass() { + echo -e "${GREEN}✓${NC} $1" + ((PASSED++)) +} + +fail() { + echo -e "${RED}✗${NC} $1" + shift + for msg in "$@"; do + echo -e " ${YELLOW}$msg${NC}" + done + ((FAILED++)) +} + +# Render the header template the way TOML does, so the assertions below run +# against the text git-cliff actually writes. git-cliff is not installed on +# every machine that runs this suite (and not in CI at all), and the header is +# static text with no Tera expressions, so decoding it here is both sufficient +# and portable. +# +# TOML multi-line basic strings drop the newline right after the opening +# delimiter; every other source line contributes its text plus one newline, and +# a trailing \n escape contributes another newline on top of that. That second +# newline is easy to add by accident and invisible in the source, which is what +# MD012 below is guarding against. +render_header() { + awk ' + /^header = """$/ { in_block = 1; next } + in_block && /^"""$/ { exit } + in_block { + line = $0 + extra = 0 + while (sub(/\\n$/, "", line)) extra++ + if (line ~ /\\/) { + print "unsupported escape in header template: " $0 > "/dev/stderr" + exit 1 + } + print line + for (i = 0; i < extra; i++) print "" + } + ' "$1" +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT INT TERM HUP +rendered="$work/header.md" + +if ! render_header "$CLIFF_TOML" >"$rendered"; then + fail "header template renders" "render_header could not decode the template" +elif [ ! -s "$rendered" ]; then + fail "header template renders" "Rendered header is empty; is the 'header = \"\"\"' block still there?" +else + pass "header template renders" + + # MD012/no-multiple-blanks. markdownlint runs over the generated changelog + # in the changelog-autoupdate workflow, and a header that trips this rule + # fails that job every week without ever touching a commit. + offenders="$(awk 'BEGIN { run = 0 } { if ($0 == "") { run++; if (run > 1) print NR } else run = 0 }' "$rendered")" + if [ -n "$offenders" ]; then + # shellcheck disable=SC2086 # Word splitting turns the line list into args + fail "header has no consecutive blank lines (MD012)" \ + "Extra blank lines at rendered line(s): $(echo $offenders | tr ' ' ',')" \ + "Rendered header:" \ + "$(cat -v "$rendered")" + else + pass "header has no consecutive blank lines (MD012)" + fi + + # The header has to end with exactly one blank line so the first '## [' + # section that follows it is separated by one blank line, not glued on. + trailing_blanks="$(awk '{ if ($0 == "") blanks++; else blanks = 0 } END { print blanks + 0 }' "$rendered")" + if [ "$trailing_blanks" -ne 1 ]; then + fail "header ends with a single blank line" \ + "Header ends with $trailing_blanks trailing blank line(s), expected 1" + else + pass "header ends with a single blank line" + fi +fi + +echo "" +echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}" +[ "$FAILED" -eq 0 ] diff --git a/tests/test-unit.sh b/tests/test-unit.sh index 7f916a2..05e8660 100755 --- a/tests/test-unit.sh +++ b/tests/test-unit.sh @@ -43,6 +43,7 @@ run_test "$TEST_SCRIPT_DIR/test-conventional-merge-commit.sh" "conventional-merg run_test "$TEST_SCRIPT_DIR/test-parse-version.sh" "release/parse-version.sh tests" run_test "$TEST_SCRIPT_DIR/test-bump-pins.sh" "release/bump-pins.sh tests" run_test "$TEST_SCRIPT_DIR/test-stamp-changelog.sh" "release/stamp-changelog.sh tests" +run_test "$TEST_SCRIPT_DIR/test-cliff-header.sh" "cliff.toml changelog header tests" # Summary echo "==========================================" From f624b0307f197ffb031f9175c7456ecfe2532c16 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Mon, 14 Sep 2026 23:12:15 -0700 Subject: [PATCH 2/4] fix(test-cliff-header.sh): guard mktemp failure The script runs under `set -uo pipefail` with no `errexit`, so a failed `mktemp -d` left `work` set to the empty string and execution continued. `rendered` then resolved to `/header.md`: as a normal user the redirect fails and the suite reports a confusing render failure, and as root it writes a stray file into the filesystem root that the `rm -rf "$work"` trap cannot clean up (it expands to a no-op on an empty operand). Check the assignment and exit before any path is built from it. Reported by qodo-code-review on PR #79. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test-cliff-header.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test-cliff-header.sh b/tests/test-cliff-header.sh index e8f4d8e..537918f 100755 --- a/tests/test-cliff-header.sh +++ b/tests/test-cliff-header.sh @@ -56,7 +56,10 @@ render_header() { ' "$1" } -work="$(mktemp -d)" +if ! work="$(mktemp -d)"; then + echo "Error: could not create a temporary directory" >&2 + exit 1 +fi trap 'rm -rf "$work"' EXIT INT TERM HUP rendered="$work/header.md" From ee335df9c6aeb8235bc205c22b0e1ec11edb5917 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Mon, 14 Sep 2026 23:13:29 -0700 Subject: [PATCH 3/4] fix(test-cliff-header.sh): match MD012 blank rule markdownlint treats a whitespace-only line as blank, but both awk checks tested for an empty line only. A header carrying a line of spaces next to an empty one therefore passed this suite while markdownlint failed it -- the exact divergence the test exists to prevent, since it stands in for the autoupdate workflow's markdownlint run. Verified against markdownlint-cli 0.48.0 with the repo's own config: a header rendering "Some text." / three spaces / empty / "More text." is reported as MD012 Actual: 2, and is now caught here too. Reported by copilot-pull-request-reviewer on PR #79 (lines 73, 86). Co-Authored-By: Claude Opus 5 (1M context) --- tests/test-cliff-header.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test-cliff-header.sh b/tests/test-cliff-header.sh index 537918f..c0d7891 100755 --- a/tests/test-cliff-header.sh +++ b/tests/test-cliff-header.sh @@ -73,7 +73,11 @@ else # MD012/no-multiple-blanks. markdownlint runs over the generated changelog # in the changelog-autoupdate workflow, and a header that trips this rule # fails that job every week without ever touching a commit. - offenders="$(awk 'BEGIN { run = 0 } { if ($0 == "") { run++; if (run > 1) print NR } else run = 0 }' "$rendered")" + # + # markdownlint counts a whitespace-only line as blank, so this check and + # the trailing-blank count below have to as well; testing for an empty + # line alone would pass a header that markdownlint still rejects. + offenders="$(awk 'BEGIN { run = 0 } { if ($0 ~ /^[[:space:]]*$/) { run++; if (run > 1) print NR } else run = 0 }' "$rendered")" if [ -n "$offenders" ]; then # shellcheck disable=SC2086 # Word splitting turns the line list into args fail "header has no consecutive blank lines (MD012)" \ @@ -86,7 +90,7 @@ else # The header has to end with exactly one blank line so the first '## [' # section that follows it is separated by one blank line, not glued on. - trailing_blanks="$(awk '{ if ($0 == "") blanks++; else blanks = 0 } END { print blanks + 0 }' "$rendered")" + trailing_blanks="$(awk '{ if ($0 ~ /^[[:space:]]*$/) blanks++; else blanks = 0 } END { print blanks + 0 }' "$rendered")" if [ "$trailing_blanks" -ne 1 ]; then fail "header ends with a single blank line" \ "Header ends with $trailing_blanks trailing blank line(s), expected 1" From 998e5f8c1e3114eaefcd8eca437ccc5120a91e04 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Mon, 14 Sep 2026 23:14:20 -0700 Subject: [PATCH 4/4] docs(cliff.toml): scope the header newline rule The comment sat under a line naming the header, body and footer keys and then said "blank lines here", which reads as a file-wide ban on trailing \n escapes. The body template 30 lines below legitimately uses them: its source lines are joined with backslash continuations, so it places newlines explicitly. Say which template the rule governs, and why the body differs, so the note that exists to stop the MD012 bug returning cannot be read as forbidding what the body already does. Reported by copilot-pull-request-reviewer on PR #79. Co-Authored-By: Claude Opus 5 (1M context) --- cliff.toml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cliff.toml b/cliff.toml index 5ea594b..efbb0e2 100644 --- a/cliff.toml +++ b/cliff.toml @@ -9,11 +9,17 @@ breaking_always_bump_major = false # header, body, and footer keys are Tera templates # https://keats.github.io/tera/docs/#introduction # -# Blank lines here are written as blank lines, never as trailing \n escapes: a -# TOML multi-line string already ends each source line with a newline, so an -# escape on top of that emits a second one. That doubling is invisible in the -# source and lands in the generated changelog as consecutive blank lines, which -# markdownlint rejects (MD012) in the changelog-autoupdate workflow. +# The rule below is about the header template specifically, not about this +# file as a whole. Blank lines in the header are written as literal blank +# lines, never as trailing \n escapes: a TOML multi-line string already ends +# each source line with a newline, so an escape on top of that emits a second +# one. That doubling is invisible in the source and lands in the generated +# changelog as consecutive blank lines, which markdownlint rejects (MD012) in +# the changelog-autoupdate workflow. +# +# The body template is the opposite case. Its source lines are joined with +# trailing backslash continuations instead of emitting one output line each, +# so the \n escapes it uses to place newlines are deliberate. header = """