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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user