From 776c74ae42d89f7ae4c4ff8fa8de45a12b306f5c Mon Sep 17 00:00:00 2001 From: Jade Young Date: Wed, 11 Nov 2026 11:06:32 +0000 Subject: [PATCH] (PE-47992) Preserve released changelog sections in module release prep gh-changelog regenerates the whole CHANGELOG.md on every run, which rewrites and backfills sections of versions that were already released. Add a "Preserve changelog history" step after "Generate changelog" that keeps the generated preamble and newest section and restores the previously released sections from the committed CHANGELOG.md. The step fails instead of losing history when the version is invalid, the generated changelog has no version sections or its newest section is not the requested version, or the committed changelog has headings it cannot restore. A committed Unreleased section is dropped with a warning. "Generate changelog" and "Update metadata.json" now read the version from inputs.version through the environment instead of interpolating github.event.inputs.version into the script. tests/module_release_prep/test_changelog_guard.sh runs the step against fixtures, under bash -e with and without pipefail, and lint.yml runs it. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/lint.yml | 8 + .github/workflows/module_release_prep.yml | 98 +++++- CLAUDE.md | 10 +- README.md | 2 +- .../test_changelog_guard.sh | 319 ++++++++++++++++++ 5 files changed, 432 insertions(+), 5 deletions(-) create mode 100755 tests/module_release_prep/test_changelog_guard.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c3505349..6498e667 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,9 @@ on: branches: - "main" +permissions: + contents: read + jobs: lint: runs-on: "ubuntu-latest" @@ -21,3 +24,8 @@ jobs: with: file_or_dir: ".github/" config_file: ".yamllint" + + - name: "Test module_release_prep changelog preservation" + run: | + python3 -c 'import yaml' 2> /dev/null || { sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends python3-yaml; } + bash tests/module_release_prep/test_changelog_guard.sh diff --git a/.github/workflows/module_release_prep.yml b/.github/workflows/module_release_prep.yml index c80e1929..fb5a4fa5 100644 --- a/.github/workflows/module_release_prep.yml +++ b/.github/workflows/module_release_prep.yml @@ -37,15 +37,109 @@ jobs: run: | export GH_HOST=github.com gh extension install chelnak/gh-changelog - gh changelog new --next-version v${{ github.event.inputs.version }} + gh changelog new --next-version "v${VERSION#v}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + + # gh-changelog regenerates the whole file from all closed PRs and tags, which + # rewrites and backfills sections of versions that were already released. + # Keep the generated preamble and newest section, and append the sections + # of previously released versions from the committed CHANGELOG.md. The step + # fails rather than lose history when the version is not a plain version, + # the generated file is unusable, its newest section is not the requested + # version, or the committed file has headings that cannot be restored. + - name: "Preserve changelog history" + env: + VERSION: ${{ inputs.version }} + run: | + # Reject anything but a plain version, which also keeps it safe to use in + # the sed expression of the "Update metadata.json" step + if [[ "$VERSION" == *$'\n'* ]] || ! [[ "$VERSION" =~ ^v?[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid version" + exit 1 + fi + # git ls-tree prints nothing when the file is untracked and fails on a real git error + tracked=$(git ls-tree --name-only HEAD -- CHANGELOG.md) + if [ -z "$tracked" ]; then + echo "No committed CHANGELOG.md, nothing to preserve" + exit 0 + fi + old=$(mktemp) + history=$(mktemp) + merged=$(mktemp) + trap 'rm -f "$old" "$history" "$merged"' EXIT + git show "HEAD:./CHANGELOG.md" > "$old" + + if ! grep -q '^## ' "$old"; then + # Nothing to restore when the file is at most a bare title. Any other + # heading is history in a style that cannot be restored, so stop. + headings=$(grep -Ec '^#{1,6}([[:space:]]|$)' "$old" || true) + titles=$(grep -Eic '^#[[:space:]]+(change ?log|changes|release notes|history)[[:space:]]*$' "$old" || true) + if [ "$headings" -gt "$titles" ] || [ "$headings" -gt 1 ]; then + echo "::error::Committed CHANGELOG.md has headings but no '## ' sections, refusing to drop its history" + exit 1 + fi + echo "::warning::Committed CHANGELOG.md has no '## ' sections, leaving generated CHANGELOG.md as is" + exit 0 + fi + if [ ! -f CHANGELOG.md ] || ! grep -q '^## \[' CHANGELOG.md; then + echo "::error::Generated CHANGELOG.md is missing or has no version sections, refusing to drop the committed history" + exit 1 + fi + + version=${VERSION#v} + # Version of a "## [vX.Y.Z](url) - date" header, without the "v" + top_version=$(grep -m1 '^## \[' CHANGELOG.md | sed 's/^## \[v\{0,1\}\([^]]*\)\].*/\1/') + if [ "$top_version" != "$version" ]; then + echo "::error::Newest generated section is $top_version, not $version, refusing to rewrite the released history" + exit 1 + fi + + # Generated preamble and newest section + awk '/^## \[/ { n++ } n < 2' CHANGELOG.md > "$merged" + + # Committed sections, minus the one for the version being prepared (a + # re-run replaces it) and any "Unreleased" section. A section starts at + # any "## " header, e.g. "## [v1.2.3](url)" or "## v1.2.3". Text before + # the first section is dropped. + PREP_VERSION="$version" awk ' + /^## / { + v = $0 + sub(/^## \[?[vV]?/, "", v) + sub(/\].*/, "", v) + sub(/[ \t\r].*/, "", v) + sub(/[:,;]+$/, "", v) + keep = (v != ENVIRON["PREP_VERSION"] && tolower(v) != "unreleased") + } + keep + ' "$old" > "$history" + + if grep -Eiq '^## \[?v?unreleased([][:space:]:,;]|$)' "$old"; then + echo "::warning::Dropping the committed Unreleased section" + fi + + if [ -s "$history" ]; then + # Ensure a blank line separates the new section from the restored + # history, using the line ending of the file + if [ -n "$(tail -n 1 "$merged" | tr -d '\r')" ]; then + if head -n 1 "$merged" | grep -q "$(printf '\r')"; then + printf '\r\n' >> "$merged" + else + echo >> "$merged" + fi + fi + cat "$history" >> "$merged" + fi + cp "$merged" CHANGELOG.md - name: "Update metadata.json" + env: + VERSION: ${{ inputs.version }} run: | current_version=$(jq --raw-output .version metadata.json) # Update version in metadata.json, only matching first occurrence - sed -i "0,/$current_version/s//${{ github.event.inputs.version }}/" $(find . -name 'metadata.json') + sed -i "0,/$current_version/s//${VERSION#v}/" $(find . -name 'metadata.json') - name: "Configure forge authentication" env: diff --git a/CLAUDE.md b/CLAUDE.md index c73bf271..a8f897ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,8 @@ The workflows split along two product lines and a lifecycle: Cross-cutting workflows: - [lint.yml](.github/workflows/lint.yml) — the **only** workflow that runs on - this repo's own pushes/PRs (not `workflow_call`); yamllints `.github/`. + this repo's own pushes/PRs (not `workflow_call`); yamllints `.github/` and runs + the tests under [tests/](tests/). - [claude-pr.yml](.github/workflows/claude-pr.yml) — auto PR review (see below). - [fork_ci_label_guard.yml](.github/workflows/fork_ci_label_guard.yml) — part of the fork-PR security model (see below). @@ -106,12 +107,17 @@ exact commits**. ## Linting -CI lints YAML only. To reproduce locally: +CI yamllints `.github/` and runs the tests under [tests/](tests/). To reproduce +locally: ```bash yamllint -c .yamllint .github/ ``` +```bash +tests/module_release_prep/test_changelog_guard.sh # needs python3 with PyYAML +``` + Config: [.yamllint](.yamllint) extends `default` and disables `document-start`, `truthy`, and `line-length`. diff --git a/README.md b/README.md index 68adc693..a3fe6cad 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The following are the workflows we currently maintain in this repository: * module_ci: runs automated unit testing CI for modules on PRs * module_pdk_update: runs `pdk update` on a module and opens/refreshes a `pdk_update` PR with the template changes * fork_ci_label_guard: strips the `allowed-for-ci` label from fork PRs on each new commit so privileged acceptance tests re-require CODEOWNER review -* module_release_prep: prepares the module for release by running necessary pre-release checks and tasks +* module_release_prep: prepares the module for release by running necessary pre-release checks and tasks, keeping the sections of previously released versions in `CHANGELOG.md` intact when it is regenerated. It fails on an invalid version, and when the regenerated changelog does not start with the requested version or the existing history cannot be restored. A committed `Unreleased` section is dropped, with a warning * module_release: handles the release process of the module, including versioning and publishing * tooling_mend_ruby: automates the usage of mend for vulnerability scanning on tools * workflow-restarter-test: tests the workflow restarter functionality diff --git a/tests/module_release_prep/test_changelog_guard.sh b/tests/module_release_prep/test_changelog_guard.sh new file mode 100755 index 00000000..57017d3a --- /dev/null +++ b/tests/module_release_prep/test_changelog_guard.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# Tests the "Preserve changelog history" step of module_release_prep.yml. +# The step's `run:` block is extracted from the workflow and executed in a +# throwaway git repo whose working-tree CHANGELOG.md simulates gh-changelog output. +# Requires python3 with PyYAML. +# Usage: tests/module_release_prep/test_changelog_guard.sh +# No -e on purpose: run_case counts failures instead of aborting on the first one. +set -u +# The cases create their own repos, so ignore a surrounding one (e.g. when run from a git hook) +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE +# Keep the developer's git config (e.g. core.autocrlf) out of the fixtures +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + +root=$(cd "$(dirname "$0")/../.." && pwd) +workflow="$root/.github/workflows/module_release_prep.yml" +step_name="Preserve changelog history" +failures=0 + +python3 -c 'import yaml' 2> /dev/null || { echo "python3 with PyYAML is required"; exit 2; } + +fx=$(mktemp -d) || { echo "mktemp failed"; exit 2; } +trap 'rm -rf "$fx"' EXIT + +# The cases below supply VERSION themselves, so check that the workflow does: +# every step that needs it reads it from inputs.version, and no step interpolates +# inputs.version or github.event.inputs into a script. +if python3 - "$workflow" <<'PY' +import sys, yaml +text = open(sys.argv[1]).read() +steps = yaml.safe_load(text)["jobs"]["release_prep"]["steps"] +for name in ("Generate changelog", "Preserve changelog history", "Update metadata.json"): + step = next(s for s in steps if s.get("name") == name) + assert step["env"]["VERSION"] == "${{ inputs.version }}", name + assert "${{" not in step["run"], name + " interpolates an expression into the script" + assert "VERSION" in step["run"], name + " does not use VERSION" +for step in steps: + assert "inputs.version" not in step.get("run", ""), step.get("name") +names = [s.get("name") for s in steps] +assert names.index("Generate changelog") < names.index("Preserve changelog history") < names.index("Update metadata.json"), "step order" +preserve = steps[names.index("Preserve changelog history")] +assert "if" not in preserve and "continue-on-error" not in preserve, "the guard must always run and fail the job" +assert '"v${VERSION#v}"' in steps[names.index("Generate changelog")]["run"] +assert "${VERSION#v}" in steps[names.index("Update metadata.json")]["run"] +assert "github.event.inputs" not in text +PY +then echo "PASS: VERSION is wired from inputs.version" +else echo "FAIL: VERSION is not wired from inputs.version"; failures=$((failures + 1)); fi + +python3 - "$workflow" "$step_name" > "$fx/step.sh" <<'PY' || { echo "step '$step_name' not found in $workflow"; exit 2; } +import sys, yaml +wf = yaml.safe_load(open(sys.argv[1])) +for step in wf["jobs"]["release_prep"]["steps"]: + if step.get("name") == sys.argv[2]: + print(step["run"]) + sys.exit(0) +sys.exit(1) +PY + +# run_case [pass|fail] [] +# With "fail" the step must exit non-zero and leave the generated file as is. +# With a (for pass or fail) the step output must also contain it. +# The version being prepared is $case_version. +# The step runs with `bash -e` (the Actions default) and again with pipefail. +run_case() { + local name=$1 old=$2 generated=$3 expected=$4 expect=${5:-pass} pattern=${6:-} flags dir rc ok + for flags in -e -eo\ pipefail; do + dir=$(mktemp -d) || { echo "mktemp failed"; exit 2; } + ( + cd "$dir" || exit 1 + git init -q . && git config user.email t@example.com && git config user.name t || exit 1 + if [ "$old" != "-" ]; then + cp "$old" CHANGELOG.md && git add CHANGELOG.md + else + : > .keep && git add .keep + fi + git -c commit.gpgsign=false commit -q -m base || exit 1 + cp "$generated" CHANGELOG.md + # shellcheck disable=SC2086 + VERSION=$case_version bash $flags "$fx/step.sh" + ) > "$dir/log" 2>&1 + rc=$? + ok=1 + if [ "$expect" = pass ]; then [ $rc -eq 0 ] || ok=0; else [ $rc -ne 0 ] || ok=0; fi + if [ -n "$pattern" ] && ! grep -q -- "$pattern" "$dir/log"; then ok=0; echo "expected output not found: $pattern" >> "$dir/log"; fi + diff -u "$expected" "$dir/CHANGELOG.md" >> "$dir/log" 2>&1 || ok=0 + if [ $ok -eq 1 ]; then + echo "PASS: $name [bash $flags]" + else + echo "FAIL: $name [bash $flags] (rc=$rc)"; cat "$dir/log"; failures=$((failures + 1)) + fi + rm -rf "$dir" + done +} + +case_version=3.39.0 +preamble='# Changelog + +All notable changes to this project will be documented in this file. +' +section() { # : one gh-changelog style section + local v=$1 d=$2; shift 2 + printf '\n## [v%s](https://github.com/o/r/tree/v%s) - %s\n\n[Full Changelog](https://github.com/o/r/compare/vprev...v%s)\n\n' "$v" "$v" "$d" "$v" + printf '%s\n' "$@" +} + +s_new=$(section 3.39.0 2026-11-10 '### Added' '' '- New feature [#750](https://github.com/o/r/pull/750) ([c](https://github.com/c))') +s_old3=$(section 3.38.3 2026-10-01 '### Added' '' '- Thing one [#692](https://github.com/o/r/pull/692) ([a](https://github.com/a))') +s_old2=$(section 3.38.2 2026-09-01 '### Other' '' '- Old other [#600](https://github.com/o/r/pull/600) ([b](https://github.com/b))') + +s_unreleased=' +## [Unreleased] + +### Added + +- WIP' + +# What is committed: two released versions. +printf '%s\n%s\n%s\n' "$preamble" "$s_old3" "$s_old2" > "$fx/old.md" + +# Generator output with the symptoms from PE-47992: the new section plus rewritten +# history (entry removed, Revert lines backfilled, header reclassified). +printf '%s\n%s\n%s\n%s\n' "$preamble" "$s_new" \ + "$(section 3.38.3 2026-10-01 '### Fixed' '' '- Revert "Thing" [#688](https://github.com/o/r/pull/688) ([a](https://github.com/a))')" \ + "$(section 3.38.2 2026-09-01 '### Added' '' '- Old other [#600](https://github.com/o/r/pull/600) ([b](https://github.com/b))')" \ + > "$fx/generated.md" + +printf '%s\n%s\n%s\n%s\n' "$preamble" "$s_new" "$s_old3" "$s_old2" > "$fx/expected.md" + +run_case "restores historical sections, keeps new top section" "$fx/old.md" "$fx/generated.md" "$fx/expected.md" + +# Re-running prep for a version already on top of the committed file replaces +# that section instead of duplicating it. +printf '%s\n%s\n%s\n%s\n' "$preamble" "$(section 3.39.0 2026-11-09 '### Added' '' '- Stale entry')" "$s_old3" "$s_old2" > "$fx/old_rerun.md" +run_case "same-version rerun replaces top section without duplicating" "$fx/old_rerun.md" "$fx/generated.md" "$fx/expected.md" + +# A committed Unreleased section is not carried over below the new section. +printf '%s\n%s\n%s\n%s\n' "$preamble" "$s_unreleased" "$s_old3" "$s_old2" > "$fx/old_unreleased.md" +run_case "committed Unreleased section is dropped with a warning" "$fx/old_unreleased.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" +sed 's/\[Unreleased\]/[unreleased]/' "$fx/old_unreleased.md" > "$fx/old_unreleased_lower.md" +run_case "committed unreleased section is dropped regardless of case" "$fx/old_unreleased_lower.md" "$fx/generated.md" "$fx/expected.md" + +# Generated file with a single section (and no trailing newline) keeps all of it +# and gets a blank line before the restored history. +printf '%s\n%s' "$preamble" "$s_new" > "$fx/generated_single.md" +run_case "single generated section without trailing newline" "$fx/old.md" "$fx/generated_single.md" "$fx/expected.md" + +# Version headers without the "v" prefix in the committed file still match. +sed 's/^## \[v3.38.3\]/## [3.38.3]/' "$fx/old.md" > "$fx/old_noprefix.md" +sed 's/^## \[v3.38.3\]/## [3.38.3]/' "$fx/expected.md" > "$fx/expected_noprefix.md" +run_case "history without v prefix is kept verbatim" "$fx/old_noprefix.md" "$fx/generated.md" "$fx/expected_noprefix.md" + +# CRLF line endings are preserved. +sed 's/$/\r/' "$fx/old_rerun.md" > "$fx/old_crlf.md" +sed 's/$/\r/' "$fx/generated.md" > "$fx/generated_crlf.md" +sed 's/$/\r/' "$fx/expected.md" > "$fx/expected_crlf.md" +run_case "CRLF files keep their history" "$fx/old_crlf.md" "$fx/generated_crlf.md" "$fx/expected_crlf.md" + +# No committed CHANGELOG.md: nothing to protect, generated file is left alone. +run_case "no committed changelog leaves generated file untouched" "-" "$fx/generated.md" "$fx/generated.md" pass "nothing to preserve" + +# Committed file without any "## " sections: leave generated file alone. +printf '# Changelog\n' > "$fx/old_empty.md" +run_case "committed changelog without sections leaves generated file untouched" "$fx/old_empty.md" "$fx/generated.md" "$fx/generated.md" pass "has no '## ' sections" + +# Headings in another style cannot be restored, so stop instead of dropping them. +printf '# Changelog\n\n# 3.38.3\n\n- thing\n' > "$fx/old_h1.md" +run_case "committed changelog with h1 sections fails" "$fx/old_h1.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" + +# Generator produced no sections while history exists: fail instead of shipping a +# changelog without history. +printf '# Changelog\n' > "$fx/generated_empty.md" +run_case "generated changelog without sections fails" "$fx/old.md" "$fx/generated_empty.md" "$fx/generated_empty.md" fail "refusing to drop the committed history" +: > "$fx/generated_blank.md" +run_case "empty generated changelog fails" "$fx/old.md" "$fx/generated_blank.md" "$fx/generated_blank.md" fail "refusing to drop the committed history" + +# CRLF where the generated section does not end in a blank line: the separator +# uses CRLF too. +printf '%s\n%s' "$preamble" "$s_new" | sed 's/$/\r/' > "$fx/generated_single_crlf.md" +sed 's/$/\r/' "$fx/expected.md" > "$fx/expected_crlf_single.md" +sed 's/$/\r/' "$fx/old.md" > "$fx/old_crlf_plain.md" +run_case "CRLF separator when the generated section has no trailing blank line" "$fx/old_crlf_plain.md" "$fx/generated_single_crlf.md" "$fx/expected_crlf_single.md" + +# Only the version being prepared is committed: nothing older to restore and no +# stray blank line at the end. +printf '%s\n%s\n' "$preamble" "$(section 3.39.0 2026-11-09 '### Added' '' '- Stale entry')" > "$fx/old_only_new.md" +printf '%s\n%s\n' "$preamble" "$s_new" > "$fx/generated_only_new.md" +run_case "only the prepared version committed" "$fx/old_only_new.md" "$fx/generated_only_new.md" "$fx/generated_only_new.md" + +# The generator emitted no section for the requested version: its newest section +# is a rewritten release, so the step fails and leaves the generated file alone. +case_version=3.40.0 +run_case "no generated section for the requested version fails" "$fx/old.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to rewrite the released history" +for bad in "" "3.39.0; id" ";x 3.39.0" "1/;e id #" "3.39.0-a/b" "3.39.0-a&b" "latest" "3.39." "3x39.0" "3.39.0-" $'3.39.0\n/;e id #' $'3.39.0\n'; do + case_version=$bad + shown=${bad//$'\n'/\\n} + run_case "invalid version '$shown' fails" "$fx/old.md" "$fx/generated.md" "$fx/generated.md" fail "Invalid version" +done +# Defensive: the step also accepts a version given with a "v" prefix. +case_version=v3.39.0 +run_case "version with v prefix is accepted" "$fx/old.md" "$fx/generated.md" "$fx/expected.md" +run_case "version with v prefix replaces a committed section of that version" "$fx/old_rerun.md" "$fx/generated.md" "$fx/expected.md" +case_version=3.39.0 + +# Headers that are not in the "## [version](url)" form still delimit sections, so +# dropping a section never swallows its neighbours. +s_bare=$(printf '\n## v3.38.3 - 2026-10-01\n\n### Added\n\n- Bare header entry') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$s_unreleased" "$s_bare" "$s_old2" > "$fx/old_mixed.md" +printf '%s\n%s\n%s\n%s\n' "$preamble" "$s_new" "$s_bare" "$s_old2" > "$fx/expected_mixed.md" +run_case "sections with other header styles survive a dropped Unreleased section" "$fx/old_mixed.md" "$fx/generated.md" "$fx/expected_mixed.md" + +# A committed file that does not use "## [version]" headers is still restored. +printf '# Changelog\n\n## Release 3.38.3\n\n- thing\n' > "$fx/old_legacy.md" +s_legacy=$(printf '\n## Release 3.38.3\n\n- thing') +printf '%s\n%s\n%s\n' "$preamble" "$s_new" "$s_legacy" > "$fx/expected_legacy.md" +run_case "committed changelog with other header style is restored" "$fx/old_legacy.md" "$fx/generated.md" "$fx/expected_legacy.md" + +# A tab after the version also ends the header. +tab_head=$(printf '\n## 3.39.0\t- 2026-11-09\n\n- stale entry') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$tab_head" "$s_old3" "$s_old2" > "$fx/old_tab.md" +run_case "tab-delimited header for the prepared version is replaced" "$fx/old_tab.md" "$fx/generated.md" "$fx/expected.md" +# Bare headers followed by a date (no brackets) +dated=$(printf '\n## Unreleased - 2026-11-01\n\n- WIP\n\n## 3.39.0 - 2026-11-09\n\n- stale entry') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$dated" "$s_old3" "$s_old2" > "$fx/old_dated.md" +run_case "bare headers with a date for Unreleased and the prepared version are dropped" "$fx/old_dated.md" "$fx/generated.md" "$fx/expected.md" +# CRLF line endings on bare headers +bare_old=$(printf '%s\n%s\n%s\n%s\n' "$preamble" "$(printf '\n## Unreleased\n\n- WIP')" "$(printf '\n## 3.39.0\n\n- stale entry')" "$s_old3") +printf '%s\n' "$bare_old" | sed 's/$/\r/' > "$fx/old_bare_crlf.md" +printf '%s\n%s\n%s\n' "$preamble" "$s_new" "$s_old3" | sed 's/$/\r/' > "$fx/expected_bare_crlf.md" +sed 's/$/\r/' "$fx/generated.md" > "$fx/generated_bare_crlf.md" +run_case "CRLF bare headers for Unreleased and the prepared version are dropped" "$fx/old_bare_crlf.md" "$fx/generated_bare_crlf.md" "$fx/expected_bare_crlf.md" pass "Dropping the committed Unreleased section" + +# A generated newest section that is not a version (e.g. Unreleased) fails. +printf '%s\n%s\n%s\n' "$preamble" "$s_unreleased" "$s_old3" > "$fx/generated_unreleased.md" +run_case "generated Unreleased section on top fails" "$fx/old.md" "$fx/generated_unreleased.md" "$fx/generated_unreleased.md" fail "Newest generated section is Unreleased" + +# Pre-release versions are accepted. +sed 's/^## \[v3.39.0\]/## [v3.39.0-rc.1]/' "$fx/generated.md" > "$fx/generated_rc.md" +sed 's/^## \[v3.39.0\]/## [v3.39.0-rc.1]/' "$fx/expected.md" > "$fx/expected_rc.md" +case_version=3.39.0-rc.1 +run_case "pre-release version is accepted" "$fx/old.md" "$fx/generated_rc.md" "$fx/expected_rc.md" +for okv in 3.39.0+build.5 3.39.0-RC-1 3; do + sed "s/^## \[v3.39.0\]/## [v$okv]/" "$fx/generated.md" > "$fx/generated_ok.md" + sed "s/^## \[v3.39.0\]/## [v$okv]/" "$fx/expected.md" > "$fx/expected_ok.md" + case_version=$okv + run_case "version '$okv' is accepted" "$fx/old.md" "$fx/generated_ok.md" "$fx/expected_ok.md" +done +case_version=3.39.0 + +# Headings of any level count as history that cannot be restored (deepest level here). +printf '# Changelog\n\n###### 3.38.3\n\n- thing\n' > "$fx/old_h6.md" +run_case "committed changelog with only level 6 headings fails" "$fx/old_h6.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" + +# Unreleased headers in any case, or followed by punctuation or a tab, are dropped with a warning. +for punct in "," ";"; do + p_unreleased=$(printf '\n## Unreleased%s notes\n\n- WIP' "$punct") + printf '%s\n%s\n%s\n%s\n' "$preamble" "$p_unreleased" "$s_old3" "$s_old2" > "$fx/old_unreleased_punct.md" + run_case "Unreleased header followed by '$punct' is dropped with a warning" "$fx/old_unreleased_punct.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" +done +colon_unreleased=$(printf '\n## Unreleased: notes\n\n- WIP') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$colon_unreleased" "$s_old3" "$s_old2" > "$fx/old_unreleased_colon.md" +run_case "Unreleased header followed by a colon is dropped with a warning" "$fx/old_unreleased_colon.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" +tabbed=$(printf '\n## Unreleased\t- 2026-11-01\n\n- WIP') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$tabbed" "$s_old3" "$s_old2" > "$fx/old_unreleased_tab.md" +run_case "tab-delimited Unreleased header is dropped with a warning" "$fx/old_unreleased_tab.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" +upper=$(printf '\n## UNRELEASED\n\n- WIP') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$upper" "$s_old3" "$s_old2" > "$fx/old_upper.md" +run_case "bare UNRELEASED header is dropped with a warning" "$fx/old_upper.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" + +# A single heading that is not a title, or a tab-separated heading, is history +# that cannot be restored. +printf '# 0.9.0\n\n- old thing\n' > "$fx/old_lone.md" +run_case "committed changelog whose only heading is a version fails" "$fx/old_lone.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" +printf '# Changelog\n\n##\t0.9.0\n\n- old\n' > "$fx/old_tabhead.md" +run_case "committed changelog with a tab-separated heading fails" "$fx/old_tabhead.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" + +# A trailing colon after the version in a header still identifies the section. +hdr=$(printf '\n## 3.39.0: stale\n\n- stale entry') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$hdr" "$s_old3" "$s_old2" > "$fx/old_colon.md" +run_case "header with a colon after the prepared version is replaced" "$fx/old_colon.md" "$fx/generated.md" "$fx/expected.md" + +# Nothing but text, or nothing at all, leaves the generated file alone. +printf 'just some text\n' > "$fx/old_text.md" +run_case "committed changelog without headings leaves generated file untouched" "$fx/old_text.md" "$fx/generated.md" "$fx/generated.md" pass "has no '## ' sections" +: > "$fx/old_blank.md" +run_case "empty committed changelog leaves generated file untouched" "$fx/old_blank.md" "$fx/generated.md" "$fx/generated.md" pass "has no '## ' sections" +for title in "Change log" "Release notes" "History" "Changes" "Changelog " $'\tChangelog'; do + printf '#%s%s\n\nSome text\n' "$([ "${title:0:1}" = $'\t' ] || echo ' ')" "$title" > "$fx/old_title.md" + run_case "bare title '${title//$'\t'/}' leaves generated file untouched" "$fx/old_title.md" "$fx/generated.md" "$fx/generated.md" pass "has no '## ' sections" +done + +# Other punctuation after the version in a header +for punct in "," ";"; do + hdr=$(printf '\n## 3.39.0%s stale\n\n- stale entry' "$punct") + printf '%s\n%s\n%s\n%s\n' "$preamble" "$hdr" "$s_old3" "$s_old2" > "$fx/old_punct.md" + run_case "header with '$punct' after the prepared version is replaced" "$fx/old_punct.md" "$fx/generated.md" "$fx/expected.md" +done + +# Versions that merely resemble the prepared one are other releases and are kept. +s_rc=$(section 3.39.0-rc.1 2026-11-05 '### Added' '' '- rc entry') +s_lookalike=$(section 3.390.0 2026-11-06 '### Added' '' '- lookalike entry') +printf '%s\n%s\n%s\n%s\n%s\n' "$preamble" "$s_rc" "$s_lookalike" "$s_old3" "$s_old2" > "$fx/old_similar.md" +printf '%s\n%s\n%s\n%s\n%s\n%s\n' "$preamble" "$s_new" "$s_rc" "$s_lookalike" "$s_old3" "$s_old2" > "$fx/expected_similar.md" +run_case "sections of similar versions are kept" "$fx/old_similar.md" "$fx/generated.md" "$fx/expected_similar.md" + +# A heading that only starts like a title is history that cannot be restored. +printf '# Changes 0.9.0\n\n- old thing\n' > "$fx/old_title_like.md" +run_case "committed changelog whose only heading starts like a title fails" "$fx/old_title_like.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" + +printf '### History\n\n- old thing\n' > "$fx/old_deep_title.md" +run_case "committed changelog whose only heading is a deep History heading fails" "$fx/old_deep_title.md" "$fx/generated.md" "$fx/generated.md" fail "refusing to drop its history" + +# A v-prefixed Unreleased header is dropped with a warning too. +v_unreleased=$(printf '\n## vUnreleased\n\n- WIP') +printf '%s\n%s\n%s\n%s\n' "$preamble" "$v_unreleased" "$s_old3" "$s_old2" > "$fx/old_v_unreleased.md" +run_case "vUnreleased header is dropped with a warning" "$fx/old_v_unreleased.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" +sed 's/vUnreleased/VUNRELEASED/' "$fx/old_v_unreleased.md" > "$fx/old_v_unreleased_upper.md" +run_case "VUNRELEASED header is dropped with a warning" "$fx/old_v_unreleased_upper.md" "$fx/generated.md" "$fx/expected.md" pass "Dropping the committed Unreleased section" + +[ "$failures" -eq 0 ] && echo "all passed" || { echo "$failures failed"; exit 1; }