diff --git a/decorate-pr/action.yml b/decorate-pr/action.yml index 193ef99..b8253c0 100644 --- a/decorate-pr/action.yml +++ b/decorate-pr/action.yml @@ -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'