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.
|
workflow token.
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
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:
|
outputs:
|
||||||
comment-id:
|
comment-id:
|
||||||
@@ -39,6 +75,25 @@ outputs:
|
|||||||
comment-url:
|
comment-url:
|
||||||
description: URL to the posted or updated PR comment.
|
description: URL to the posted or updated PR comment.
|
||||||
value: ${{ steps.post-comment.outputs.comment_url }}
|
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:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
@@ -124,6 +179,187 @@ runs:
|
|||||||
|
|
||||||
rm -f "$tmp_file"
|
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
|
- name: Build PR comment markdown
|
||||||
id: build-comment
|
id: build-comment
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -132,6 +368,12 @@ runs:
|
|||||||
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
|
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
|
||||||
BADGE_URL: ${{ inputs.badge-url }}
|
BADGE_URL: ${{ inputs.badge-url }}
|
||||||
UNRELEASED: ${{ steps.extract-changelog.outputs.unreleased_entries }}
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -154,6 +396,56 @@ EOF
|
|||||||
|
|
||||||
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
|
# Changelog section
|
||||||
if [[ -n "$UNRELEASED" ]]; then
|
if [[ -n "$UNRELEASED" ]]; then
|
||||||
cat >> "$tmp_file" << 'EOF'
|
cat >> "$tmp_file" << 'EOF'
|
||||||
|
|||||||
Reference in New Issue
Block a user