From aafc0dcb8bc1a6a964bb8f1ea3b29421c1c3fef9 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 18:55:40 +0100 Subject: [PATCH 1/2] feat: add CHANGELOG validation to preflight script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPDX-FileCopyrightText: 2025 SecPal SPDX-License-Identifier: MIT Syncs CHANGELOG validation logic from api repo (PR #77) to contracts. Changes: - Validates [Unreleased] section exists on feature/fix/refactor branches - Checks for minimum content (MIN_CHANGELOG_LINES=3) - Exempts docs/*, chore/*, ci/*, test/* branches - Robust parsing handles [Unreleased] as last section (edge case fix) - Uses configurable variables for maintainability Context: Multi-repo script synchronization initiative. Ensures consistent CHANGELOG policy enforcement across all SecPal repos. Benefits: - ✅ Prevents "forgot to update CHANGELOG" PR comments - ✅ Catches empty [Unreleased] sections (copy-paste artifacts) - ✅ Smart exemptions for docs-only branches - ✅ Early feedback (local preflight vs CI failure) Related: - api #77: Original CHANGELOG validation implementation - frontend #55: Parallel sync to frontend repo - .github #168: Template documentation --- scripts/preflight.sh | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index d356897..0a672e8 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -123,7 +123,45 @@ elif [ -f yarn.lock ] && command -v yarn >/dev/null 2>&1; then fi fi -# 3) Check PR size locally (against BASE) +# 3) CHANGELOG validation (for non-docs branches) +# Branch prefixes that are exempt from CHANGELOG updates (configuration) +CHANGELOG_EXEMPT_PREFIXES="^(docs|chore|ci|test)/" +# Minimum lines in [Unreleased] to consider it non-empty +# Typically: 3 lines = one line each for Added, Changed, Fixed sections +MIN_CHANGELOG_LINES=3 + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +if [ -f CHANGELOG.md ] && [ "$CURRENT_BRANCH" != "main" ] && [[ ! "$CURRENT_BRANCH" =~ $CHANGELOG_EXEMPT_PREFIXES ]]; then + # Check if CHANGELOG has [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "❌ CHANGELOG.md missing [Unreleased] section" >&2 + echo "Tip: Every feature/fix/refactor branch must update CHANGELOG.md" >&2 + echo "Exempt branches: docs/*, chore/*, ci/*, test/*" >&2 + exit 1 + fi + + # Check if there's actual content after [Unreleased] (robust to last/only section) + # Find line number of [Unreleased], then extract content up to next heading or EOF + UNRELEASED_START=$(grep -n '^## \[Unreleased\]' CHANGELOG.md | cut -d: -f1) + if [ -n "$UNRELEASED_START" ]; then + # Find next heading after [Unreleased], or use EOF if none found + UNRELEASED_END=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | grep -n '^## ' | head -1 | cut -d: -f1) + if [ -n "$UNRELEASED_END" ]; then + # Extract content between [Unreleased] and next heading + UNRELEASED_CONTENT=$(sed -n "$((UNRELEASED_START + 1)),$((UNRELEASED_START + UNRELEASED_END - 1))p" CHANGELOG.md | grep -v '^##' | grep -v '^$' | grep -v '^' | wc -l) + else + # [Unreleased] is the last section, extract all remaining content + UNRELEASED_CONTENT=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | grep -v '^##' | grep -v '^$' | grep -v '^' | wc -l) + fi + + if [ "$UNRELEASED_CONTENT" -lt "$MIN_CHANGELOG_LINES" ]; then + echo "⚠️ Warning: [Unreleased] section appears empty in CHANGELOG.md" >&2 + echo "Did you forget to document your changes?" >&2 + fi + fi +fi + +# 4) Check PR size locally (against BASE) if ! git rev-parse -q --verify "origin/$BASE" >/dev/null 2>&1; then echo "Warning: Cannot verify base branch origin/$BASE - skipping PR size check." >&2 echo "Tip: Run 'git fetch origin $BASE' to enable PR size checking." >&2 From 2e52203d20d3aae8f311e609f7954dc3ebc4a466 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 19:15:40 +0100 Subject: [PATCH 2/2] fix: address Copilot feedback (case statement, grep -nE, HTML patterns, MIN_CHANGELOG_LINES) - Replace bash-specific [[ =~ ]] with POSIX case statement for portability - Use grep -nE for robust [Unreleased] matching (supports Keep a Changelog links) - Extract duplicated grep chains to filter_changelog_content() with whitespace tolerance - Clarify MIN_CHANGELOG_LINES comment about what is counted (substantive content only) Addresses all 4 Copilot review comments on PR #39 --- scripts/preflight.sh | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 0a672e8..f868cc6 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -127,11 +127,25 @@ fi # Branch prefixes that are exempt from CHANGELOG updates (configuration) CHANGELOG_EXEMPT_PREFIXES="^(docs|chore|ci|test)/" # Minimum lines in [Unreleased] to consider it non-empty -# Typically: 3 lines = one line each for Added, Changed, Fixed sections +# This counts substantive content lines (not headings, blanks, or HTML comments) +# Minimum 3 ensures at least some documentation (e.g., 1-2 bullet points) MIN_CHANGELOG_LINES=3 +# Helper function to filter CHANGELOG content (POSIX-compliant with whitespace tolerance) +filter_changelog_content() { + grep -Ev '^##' | grep -Ev '^$' | grep -Ev '^[[:space:]]*' +} + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") -if [ -f CHANGELOG.md ] && [ "$CURRENT_BRANCH" != "main" ] && [[ ! "$CURRENT_BRANCH" =~ $CHANGELOG_EXEMPT_PREFIXES ]]; then +# Use POSIX-compliant case statement instead of bash-specific [[ =~ ]] for portability +BRANCH_IS_EXEMPT=false +case "$CURRENT_BRANCH" in + docs/*|chore/*|ci/*|test/*) + BRANCH_IS_EXEMPT=true + ;; +esac + +if [ -f CHANGELOG.md ] && [ "$CURRENT_BRANCH" != "main" ] && [ "$BRANCH_IS_EXEMPT" = false ]; then # Check if CHANGELOG has [Unreleased] section if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then echo "❌ CHANGELOG.md missing [Unreleased] section" >&2 @@ -142,16 +156,17 @@ if [ -f CHANGELOG.md ] && [ "$CURRENT_BRANCH" != "main" ] && [[ ! "$CURRENT_BRAN # Check if there's actual content after [Unreleased] (robust to last/only section) # Find line number of [Unreleased], then extract content up to next heading or EOF - UNRELEASED_START=$(grep -n '^## \[Unreleased\]' CHANGELOG.md | cut -d: -f1) + # Use grep -nE for robustness with Keep a Changelog format (supports links) + UNRELEASED_START=$(grep -nE '^## \[Unreleased\]' CHANGELOG.md | cut -d: -f1) if [ -n "$UNRELEASED_START" ]; then # Find next heading after [Unreleased], or use EOF if none found UNRELEASED_END=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | grep -n '^## ' | head -1 | cut -d: -f1) if [ -n "$UNRELEASED_END" ]; then - # Extract content between [Unreleased] and next heading - UNRELEASED_CONTENT=$(sed -n "$((UNRELEASED_START + 1)),$((UNRELEASED_START + UNRELEASED_END - 1))p" CHANGELOG.md | grep -v '^##' | grep -v '^$' | grep -v '^' | wc -l) + # Extract content between [Unreleased] and next heading (using helper function) + UNRELEASED_CONTENT=$(sed -n "$((UNRELEASED_START + 1)),$((UNRELEASED_START + UNRELEASED_END - 1))p" CHANGELOG.md | filter_changelog_content | wc -l) else - # [Unreleased] is the last section, extract all remaining content - UNRELEASED_CONTENT=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | grep -v '^##' | grep -v '^$' | grep -v '^' | wc -l) + # [Unreleased] is the last section, extract all remaining content (using helper function) + UNRELEASED_CONTENT=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | filter_changelog_content | wc -l) fi if [ "$UNRELEASED_CONTENT" -lt "$MIN_CHANGELOG_LINES" ]; then