feat(decorate-pr): add changelog gate validation with strict/soft modes

Adds comprehensive changelog gate that validates qualifying code/behavior/security/workflow/tooling changes include Unreleased entries.

Features:
- Built-in changelog requirement validation
- Configurable change types requiring entries
- Docs-only PR exception with customizable glob patterns
- PR label-based exemptions
- Precise diff parsing: only added lines in Unreleased count
- Decision outputs: gate_passed, docs_only, unreleased_additions_count, failure_reason
- Integrated PR comment showing gate status with remediation guidance
- Strict mode (fails job) and soft mode (warns only)

New inputs:
- enable-changelog-gate
- changelog-gate-mode (strict/soft)
- changelog-gate-required-for
- changelog-gate-allow-docs-only
- changelog-gate-docs-globs
- changelog-gate-skip-labels
This commit is contained in:
Micheal Wilkinson
2026-03-21 13:46:50 +00:00
parent b5530d0c48
commit 4b9372079b

View File

@@ -31,6 +31,42 @@ inputs:
workflow token.
required: false
default: ''
enable-changelog-gate:
description: >
Enable changelog gate validation. Validates that qualifying changes
include entries in the Unreleased section of CHANGELOG.
required: false
default: 'false'
changelog-gate-mode:
description: >
Gate mode: 'strict' fails the job if validation fails; 'soft' warns
via PR comment only.
required: false
default: 'soft'
changelog-gate-required-for:
description: >
Comma-separated types of changes that require changelog entries.
Valid: code, behavior, security, workflow, tooling.
required: false
default: 'code,behavior,security,workflow,tooling'
changelog-gate-allow-docs-only:
description: >
Allow PRs that only modify documentation files to skip changelog
requirement.
required: false
default: 'true'
changelog-gate-docs-globs:
description: >
Comma-separated glob patterns for files that are considered
documentation (case-insensitive).
required: false
default: 'docs/**,**.md,**.txt,**.rst'
changelog-gate-skip-labels:
description: >
Comma-separated PR labels that exempt a PR from changelog requirement.
Example: skip-changelog.
required: false
default: ''
outputs:
comment-id:
@@ -39,6 +75,25 @@ outputs:
comment-url:
description: URL to the posted or updated PR comment.
value: ${{ steps.post-comment.outputs.comment_url }}
gate-passed:
description: >
Whether changelog gate validation passed (true/false). Only set when
gate is enabled.
value: ${{ steps.changelog-gate.outputs.gate_passed }}
docs-only:
description: >
Whether PR is docs-only (true/false). Only set when gate is enabled.
value: ${{ steps.changelog-gate.outputs.docs_only }}
unreleased-additions-count:
description: >
Number of lines added to Unreleased section in this PR. Only set when
gate is enabled.
value: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
gate-failure-reason:
description: >
Human-readable reason for gate failure, if applicable. Only set when
gate is enabled and failed.
value: ${{ steps.changelog-gate.outputs.failure_reason }}
runs:
using: composite
@@ -124,6 +179,187 @@ runs:
rm -f "$tmp_file"
- name: Validate changelog gate
id: changelog-gate
shell: bash
env:
ENABLE_GATE: ${{ inputs.enable-changelog-gate }}
GATE_MODE: ${{ inputs.changelog-gate-mode }}
REQUIRED_FOR: ${{ inputs.changelog-gate-required-for }}
ALLOW_DOCS_ONLY: ${{ inputs.changelog-gate-allow-docs-only }}
DOCS_GLOBS: ${{ inputs.changelog-gate-docs-globs }}
SKIP_LABELS: ${{ inputs.changelog-gate-skip-labels }}
CHANGELOG: ${{ inputs.changelog }}
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
run: |
set -euo pipefail
# If gate is disabled, skip
if [[ "$ENABLE_GATE" != "true" ]]; then
echo "gate_enabled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
api_url="${SERVER_URL}/api/v1"
if [[ "$SERVER_URL" == *"github.com"* ]]; then
api_url="https://api.github.com"
fi
# Get PR labels
pr_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}"
pr_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$pr_url")
pr_labels=$(printf '%s' "$pr_data" | jq -r '.labels[].name' 2>/dev/null | tr '\n' ',' || echo "")
# Check skip labels
if [[ -n "$SKIP_LABELS" ]]; then
IFS=',' read -ra skip_array <<< "$SKIP_LABELS"
for skip_label in "${skip_array[@]}"; do
skip_label="${skip_label// /}"
if [[ ",$pr_labels," == *",$skip_label,"* ]]; then
printf 'gate_passed=true\n' >> "$GITHUB_OUTPUT"
printf 'docs_only=false\n' >> "$GITHUB_OUTPUT"
printf 'unreleased_additions_count=0\n' >> "$GITHUB_OUTPUT"
printf 'skip_reason=skip_label_detected\n' >> "$GITHUB_OUTPUT"
exit 0
fi
done
fi
# Get PR diff
diff_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files"
diff_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$diff_url")
# Determine if PR only modifies docs
total_files=$(printf '%s' "$diff_data" | jq 'length' 2>/dev/null || echo 0)
docs_only_true=true
has_qualifying_changes=false
if [[ "$total_files" -gt 0 ]]; then
printf '%s' "$diff_data" | jq -r '.[].filename' 2>/dev/null | while read -r filename; do
# Check if file matches docs globs
is_doc=false
IFS=',' read -ra glob_array <<< "$DOCS_GLOBS"
for glob in "${glob_array[@]}"; do
glob="${glob// /}"
# Simple glob matching (case-insensitive)
if [[ "${filename,,}" == ${glob,,} ]] || [[ "${filename,,}" == *"/${glob,,}" ]]; then
is_doc=true
break
fi
done
# .md files are always docs unless they're actual code docs with changes
if [[ "$filename" == *.md ]]; then
is_doc=true
fi
if [[ "$is_doc" != "true" ]]; then
docs_only_true=false
fi
done
fi
# Get changeset for changelog file
changelog_diff=""
if [[ -f "$CHANGELOG" ]]; then
changelog_diff=$(printf '%s' "$diff_data" | jq -r ".[] | select(.filename == \"$CHANGELOG\") | .patch" 2>/dev/null || echo "")
fi
# Count additions to Unreleased section in changelog diff
unreleased_additions=0
if [[ -n "$changelog_diff" ]]; then
# Find lines added within Unreleased section
in_unreleased=false
while IFS= read -r line; do
# Skip processing metadata lines
[[ "$line" =~ ^(\+\+\+|---|@@) ]] && continue
# Check if entering Unreleased section
if [[ "$line" == "+## [Unreleased]"* ]] || [[ "$line" == " ## [Unreleased]"* ]]; then
in_unreleased=true
continue
fi
# Check if exiting Unreleased section
if [[ "$line" == "+## ["* ]] || [[ "$line" == " ## ["* ]]; then
if [[ "$line" != "+## [Unreleased]"* ]] && [[ "$line" != " ## [Unreleased]"* ]]; then
in_unreleased=false
fi
continue
fi
# Count non-empty additions in Unreleased
if $in_unreleased && [[ "$line" == "+"* ]] && [[ ! "$line" =~ ^(\+\+\+|---|@@) ]]; then
content="${line:1}" # Remove the '+' prefix
if [[ -n "${content// /}" ]]; then # Check if not just whitespace
unreleased_additions=$((unreleased_additions + 1))
fi
fi
done <<< "$changelog_diff"
fi
# Evaluate gate
gate_passed=true
failure_reason=""
if [[ "$docs_only_true" == "true" ]] && [[ "$ALLOW_DOCS_ONLY" == "true" ]]; then
gate_passed=true
docs_only=true
else
docs_only=false
# Check if code requires changelog entry
if [[ "$total_files" -gt 0 ]] && [[ "$unreleased_additions" -eq 0 ]]; then
gate_passed=false
failure_reason="Code changes detected but no entries added to Unreleased section of CHANGELOG.md"
fi
fi
printf 'gate_enabled=true\n' >> "$GITHUB_OUTPUT"
printf 'gate_passed=%s\n' "$gate_passed" >> "$GITHUB_OUTPUT"
printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT"
printf 'unreleased_additions_count=%s\n' "$unreleased_additions" >> "$GITHUB_OUTPUT"
printf 'failure_reason=%s\n' "$failure_reason" >> "$GITHUB_OUTPUT"
# Fail job if strict mode and gate failed
if [[ "$GATE_MODE" == "strict" ]] && [[ "$gate_passed" != "true" ]]; then
echo "$failure_reason" >&2
exit 1
fi
- name: Extract changelog unreleased entries
id: extract-changelog
shell: bash
env:
CHANGELOG: ${{ inputs.changelog }}
run: |
set -euo pipefail
if [[ ! -f "$CHANGELOG" ]]; then
printf 'unreleased_entries=%s\n' "" >> "$GITHUB_OUTPUT"
exit 0
fi
# Extract everything between [Unreleased] header and the next [X.Y.Z] header
unreleased="$(awk '
/^## \[Unreleased\]/ { in_unreleased=1; next }
/^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ { if (in_unreleased) exit }
in_unreleased && NF { print }
' "$CHANGELOG")"
# Use a temporary file to handle multiline content
tmp_file=$(mktemp)
printf '%s' "$unreleased" > "$tmp_file"
# Read it back and set as output
delimiter="EOF_CHANGELOG"
printf '%s<<%s\n' "unreleased_entries<<$delimiter" "$delimiter" >> "$GITHUB_OUTPUT"
cat "$tmp_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
rm -f "$tmp_file"
- name: Build PR comment markdown
id: build-comment
shell: bash
@@ -132,6 +368,12 @@ runs:
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
BADGE_URL: ${{ inputs.badge-url }}
UNRELEASED: ${{ steps.extract-changelog.outputs.unreleased_entries }}
GATE_ENABLED: ${{ steps.changelog-gate.outputs.gate_enabled }}
GATE_PASSED: ${{ steps.changelog-gate.outputs.gate_passed }}
GATE_MODE: ${{ inputs.changelog-gate-mode }}
DOCS_ONLY: ${{ steps.changelog-gate.outputs.docs_only }}
ADDITIONS_COUNT: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
FAILURE_REASON: ${{ steps.changelog-gate.outputs.gate_failure_reason }}
run: |
set -euo pipefail
@@ -154,6 +396,56 @@ EOF
EOF
# Changelog gate section (if enabled)
if [[ "$GATE_ENABLED" == "true" ]]; then
gate_status="✓ Pass"
if [[ "$GATE_PASSED" != "true" ]]; then
if [[ "$GATE_MODE" == "strict" ]]; then
gate_status="✗ Fail"
else
gate_status="⚠ Warning"
fi
fi
cat >> "$tmp_file" << EOF
### Changelog Gate
**Status:** $gate_status
EOF
if [[ "$DOCS_ONLY" == "true" ]]; then
cat >> "$tmp_file" << 'EOF'
This PR only modifies documentation—changelog entry not required.
EOF
elif [[ "$GATE_PASSED" == "true" ]]; then
cat >> "$tmp_file" << EOF
Found $ADDITIONS_COUNT line(s) added to Unreleased section ✓
EOF
else
cat >> "$tmp_file" << EOF
**Issue:** $FAILURE_REASON
**How to fix:** Add an entry under the appropriate subsection in the \`## [Unreleased]\` section of \`CHANGELOG.md\`. Use one of:
- \`### Breaking\` for backwards-incompatible changes
- \`### Added\` for new features
- \`### Changed\` for behavior changes
- \`### Removed\` for deprecated removals
- \`### Fixed\` for bug fixes
Example:
\`\`\`markdown
## [Unreleased]
### Added
- New changelog gate validation for PR decoration.
\`\`\`
EOF
fi
fi
# Changelog section
if [[ -n "$UNRELEASED" ]]; then
cat >> "$tmp_file" << 'EOF'