From aafc0dcb8bc1a6a964bb8f1ea3b29421c1c3fef9 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 18:55:40 +0100 Subject: [PATCH 1/5] 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/5] 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 From 795f533962303a316cb842d0c9f29efa8797124d Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 20:29:56 +0100 Subject: [PATCH 3/5] fix: improve pre-push error message for PR size violations Same fix as SecPal/api#81 - enhanced error message clarity with: - Visual separators for attention - Exact breakdown (insertions + deletions) - Numbered options for bypass - Clear "Push aborted" message See SecPal/api#80 for problem description --- scripts/preflight.sh | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index f868cc6..4f1658c 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -239,18 +239,38 @@ else echo "Preflight OK · Changed lines: 0 (after exclusions)" exit 0 else - # Use --numstat for locale-independent parsing (sum insertions + deletions) - CHANGED=$(echo "$DIFF_OUTPUT" | awk '{ins+=$1; del+=$2} END {print ins+del+0}') - [ -z "$CHANGED" ] && CHANGED=0 + # Use --numstat for locale-independent parsing + INSERTIONS=$(echo "$DIFF_OUTPUT" | awk '{ins+=$1} END {print ins+0}') + DELETIONS=$(echo "$DIFF_OUTPUT" | awk '{del+=$2} END {print del+0}') + CHANGED=$((INSERTIONS + DELETIONS)) if [ "$CHANGED" -gt 600 ]; then # Check for override file (similar to GitHub label for exceptional cases) if [ -f "$ROOT_DIR/.preflight-allow-large-pr" ]; then echo "⚠️ Large PR override active ($CHANGED > 600 lines). Remove .preflight-allow-large-pr when done." >&2 else - echo "PR too large ($CHANGED > 600 lines). Please split into smaller slices." >&2 - echo "Tip: Lock files and license files are already excluded. See .preflight-exclude for details." >&2 - echo "For exceptional cases, create .preflight-allow-large-pr to override this check." >&2 + echo "" >&2 + echo "═══════════════════════════════════════════════════════════════" >&2 + echo "❌ PRE-PUSH CHECK FAILED: PR TOO LARGE" >&2 + echo "═══════════════════════════════════════════════════════════════" >&2 + echo "" >&2 + echo "Your changes: $CHANGED lines ($INSERTIONS insertions, $DELETIONS deletions)" >&2 + echo "Maximum allowed: 600 lines per PR" >&2 + echo "" >&2 + echo "Action required: Split changes into smaller, focused PRs" >&2 + echo "" >&2 + echo "💡 Available options:" >&2 + echo " 1. Split PR: Recommended approach" >&2 + echo " 2. Override check: touch .preflight-allow-large-pr" >&2 + echo " 3. Bypass hook: git push --no-verify (not recommended)" >&2 + echo "" >&2 + echo "Note: Lock files and license files are already excluded" >&2 + echo " See .preflight-exclude for custom exclusion patterns" >&2 + echo "" >&2 + echo "═══════════════════════════════════════════════════════════════" >&2 + echo "Push aborted. Fix the issue above and try again." >&2 + echo "═══════════════════════════════════════════════════════════════" >&2 + echo "" >&2 exit 2 fi else From bb2cd9c0d2ee8771614e5c90777f8b4f92cf9e99 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 20:39:01 +0100 Subject: [PATCH 4/5] fix: remove --no-verify suggestion per Copilot review --- scripts/preflight.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 4f1658c..17ab788 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -262,7 +262,6 @@ else echo "💡 Available options:" >&2 echo " 1. Split PR: Recommended approach" >&2 echo " 2. Override check: touch .preflight-allow-large-pr" >&2 - echo " 3. Bypass hook: git push --no-verify (not recommended)" >&2 echo "" >&2 echo "Note: Lock files and license files are already excluded" >&2 echo " See .preflight-exclude for custom exclusion patterns" >&2 From 6230358897dd181851892588384a30410b90b69a Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 20:50:27 +0100 Subject: [PATCH 5/5] refactor: optimize CHANGELOG validation per Copilot review - Combine multiple grep -Ev calls into single grep with pattern group - Use grep -m 1 for early termination (performance improvement) - Fix sed range calculation (UNRELEASED_END is relative, not absolute) - Add clarifying comments about CHANGELOG_EXEMPT_PREFIXES and case sync - Improve warning message with specific line count and non-blocking note Addresses Copilot review comments in PR #40 --- scripts/preflight.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 17ab788..07d84b7 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -124,8 +124,9 @@ elif [ -f yarn.lock ] && command -v yarn >/dev/null 2>&1; then fi # 3) CHANGELOG validation (for non-docs branches) -# Branch prefixes that are exempt from CHANGELOG updates (configuration) -CHANGELOG_EXEMPT_PREFIXES="^(docs|chore|ci|test)/" +# Branch prefixes that are exempt from CHANGELOG updates +# Note: These must be kept in sync with the case statement below +CHANGELOG_EXEMPT_PREFIXES="docs chore ci test" # Minimum lines in [Unreleased] to consider it non-empty # This counts substantive content lines (not headings, blanks, or HTML comments) # Minimum 3 ensures at least some documentation (e.g., 1-2 bullet points) @@ -133,11 +134,12 @@ 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:]]*' + grep -Ev '(^##|^$|^[[:space:]]*)' } CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") # Use POSIX-compliant case statement instead of bash-specific [[ =~ ]] for portability +# The case patterns below must match the prefixes in CHANGELOG_EXEMPT_PREFIXES BRANCH_IS_EXEMPT=false case "$CURRENT_BRANCH" in docs/*|chore/*|ci/*|test/*) @@ -160,18 +162,20 @@ if [ -f CHANGELOG.md ] && [ "$CURRENT_BRANCH" != "main" ] && [ "$BRANCH_IS_EXEMP 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) + # Use grep -m 1 to stop after first match for better performance + UNRELEASED_END=$(tail -n +"$((UNRELEASED_START + 1))" CHANGELOG.md | grep -n -m 1 '^## ' | cut -d: -f1) if [ -n "$UNRELEASED_END" ]; then # 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) + # UNRELEASED_END is relative line number from tail, so add to UNRELEASED_START without -1 + UNRELEASED_CONTENT=$(sed -n "$((UNRELEASED_START + 1)),$((UNRELEASED_START + UNRELEASED_END))p" CHANGELOG.md | filter_changelog_content | wc -l) else # [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 - echo "⚠️ Warning: [Unreleased] section appears empty in CHANGELOG.md" >&2 - echo "Did you forget to document your changes?" >&2 + echo "⚠️ Warning: [Unreleased] section appears empty in CHANGELOG.md (found fewer than $MIN_CHANGELOG_LINES content lines)" >&2 + echo "This is non-blocking, but please add a summary of your changes to the [Unreleased] section before merging." >&2 fi fi fi