name: 'Tools: Check Changelog' on: pull_request: types: - 'opened' - 'synchronize' - 'reopened' - 'labeled' - 'unlabeled' branches: - 'master' - 'v5.*' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: {} jobs: test-changelog-attribution: if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: block allowed-endpoints: > api.github.com:443 github.com:443 objects.githubusercontent.com:443 release-assets.githubusercontent.com:443 pypi.org:443 files.pythonhosted.org:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 persist-credentials: false - name: Fetch PR base ref for tj-actions/changed-files env: BASE_REF: ${{ github.event.pull_request.base.ref }} run: git fetch --depth=1 origin "${BASE_REF}" - name: Get changed files id: changed-files uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | .github/scripts/changelog_attribution.py .github/workflows/pr-check-changelog.yml .github/workflows/compile-changelogs.yml .github/towncrier/template.md.jinja */towncrier.toml tests/github/** - name: Set up Python if: steps.changed-files.outputs.any_changed == 'true' uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.12' - name: Test changelog attribution if: steps.changed-files.outputs.any_changed == 'true' run: | python3 -m pip install --user --disable-pip-version-check pytest==9.0.3 towncrier==25.8.0 python3 -m pytest tests/github check-changelog: if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write env: MONITORED_FOLDERS: 'api ui prowler mcp_server' steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: block allowed-endpoints: > api.github.com:443 github.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Fetch PR base ref for tj-actions/changed-files env: BASE_REF: ${{ github.event.pull_request.base.ref }} run: git fetch --depth=1 origin "${BASE_REF}" - name: Get changed files id: changed-files uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** ui/** prowler/** mcp_server/** uv.lock pyproject.toml - name: Check for folder changes and changelog fragment presence id: check-folders run: | fragment_name_re='^[A-Za-z0-9][A-Za-z0-9._-]*\.(added|changed|deprecated|removed|fixed|security)(\.[0-9]+)?\.md$' manual_pr_link_re='(\[\(#[0-9]+\)\]|\[#[0-9]+\]\(|\(#[0-9]+\)|github\.com/[^[:space:]/]+/[^[:space:]/]+/(pull|issues)/[0-9]+)' folder_alt=$(echo "$MONITORED_FOLDERS" | tr ' ' '|') missing_fragments="" invalid_fragments="" linked_fragments="" handwritten_changelogs="" all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n') added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n') added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n') # Returns success if the folder has a valid fragment added, modified, or renamed. has_changelog_update() { local folder="$1" if echo "$added_modified_or_renamed" | grep "^${folder}/changelog.d/" | sed "s|^${folder}/changelog.d/||" | grep -qE "$fragment_name_re"; then return 0 fi return 1 } if [[ "${STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED}" == "true" ]]; then # Check monitored folders for folder in $MONITORED_FOLDERS; do if echo "$all_changed" | grep -q "^${folder}/CHANGELOG.md$"; then echo "Direct CHANGELOG.md edits are not allowed for ${folder}/" handwritten_changelogs="${handwritten_changelogs}- \`${folder}/CHANGELOG.md\`"$'\n' fi changed_in_folder=$(echo "$all_changed" | grep "^${folder}/" | grep -v "^${folder}/CHANGELOG.md$" || true) if [ -n "$changed_in_folder" ]; then echo "Detected changes in ${folder}/" if ! has_changelog_update "$folder"; then echo "No changelog fragment found for ${folder}/" missing_fragments="${missing_fragments}- \`${folder}\`"$'\n' fi fi done # Check root-level dependency files (uv.lock, pyproject.toml) # These are associated with the prowler folder changelog root_deps_changed=$(echo "$all_changed" | grep -E "^(uv\.lock|pyproject\.toml)$" || true) if [ -n "$root_deps_changed" ]; then echo "Detected changes in root dependency files: $root_deps_changed" if ! has_changelog_update "prowler"; then # Only add if prowler wasn't already flagged if ! echo "$missing_fragments" | grep -q "prowler"; then echo "No changelog fragment found for root dependency changes" missing_fragments="${missing_fragments}- \`prowler\` (root dependency files changed)"$'\n' fi fi fi # Validate the filename of every fragment added by this PR added_fragments=$(echo "$added_or_renamed" | grep -E "^(${folder_alt})/changelog\.d/" || true) for fragment in $added_fragments; do name=$(basename "$fragment") if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ]; then continue fi if ! echo "$name" | grep -qE "$fragment_name_re"; then echo "Invalid fragment filename: $fragment" invalid_fragments="${invalid_fragments}- \`${fragment}\`"$'\n' fi done # Lint fragment content: the PR link is attached automatically at # compile time, so a hand-written PR or issue link would be wrong touched_fragments=$(echo "$added_modified_or_renamed" | grep -E "^(${folder_alt})/changelog\.d/" || true) for fragment in $touched_fragments; do name=$(basename "$fragment") if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ] || [ ! -f "$fragment" ]; then continue fi if grep -qE "$manual_pr_link_re" "$fragment"; then echo "Fragment contains a hand-written PR or issue link: $fragment" linked_fragments="${linked_fragments}- \`${fragment}\`"$'\n' fi done fi # Suggest a slug derived from the branch name for the bot comment suggested_slug=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's|.*/||; s/[^a-z0-9._-]/-/g; s/^[^a-z0-9]*//') if [ -z "$suggested_slug" ]; then suggested_slug="my-change" fi fragment_help="A changelog fragment is a small Markdown file named \`..md\` under \`/changelog.d/\`, where \`\` is one of \`added\`, \`changed\`, \`deprecated\`, \`removed\`, \`fixed\` or \`security\`. Its content is the changelog entry text, without the PR link (added automatically at release time) and without a trailing period. For example: \`\`\` echo 'Entry text describing the change' > /changelog.d/${suggested_slug}.fixed.md \`\`\` If this PR does not need a changelog entry, add the \`no-changelog\` label instead." if [ -n "$missing_fragments" ] || [ -n "$invalid_fragments" ] || [ -n "$linked_fragments" ] || [ -n "$handwritten_changelogs" ]; then comment_body="" if [ -n "$missing_fragments" ]; then comment_body="⚠️ **Changes detected in the following folders without a changelog fragment:**"$'\n\n'"${missing_fragments}"$'\n' fi if [ -n "$invalid_fragments" ]; then comment_body="${comment_body}⚠️ **Changelog fragment filenames that do not follow the naming convention:**"$'\n\n'"${invalid_fragments}"$'\n' fi if [ -n "$linked_fragments" ]; then comment_body="${comment_body}⚠️ **Changelog fragments containing a hand-written PR or issue link (remove it; the PR link is attached automatically at release time):**"$'\n\n'"${linked_fragments}"$'\n' fi if [ -n "$handwritten_changelogs" ]; then comment_body="${comment_body}⚠️ **Direct \`CHANGELOG.md\` edits are not allowed in regular PRs:**"$'\n\n'"${handwritten_changelogs}"$'\n' fi comment_body="${comment_body}${fragment_help}" else comment_body="✅ All required changelog fragments are present." fi write_multiline_output() { local name="$1" local value="$2" local delimiter while true; do delimiter="EOF_$(openssl rand -hex 16)" if ! grep -qxF "$delimiter" <<< "$value"; then break fi done { echo "${name}<<${delimiter}" if [ -n "$value" ]; then printf '%s\n' "$value" fi echo "${delimiter}" } >> "$GITHUB_OUTPUT" } write_multiline_output "missing_fragments" "$missing_fragments" write_multiline_output "invalid_fragments" "$invalid_fragments" write_multiline_output "linked_fragments" "$linked_fragments" write_multiline_output "handwritten_changelogs" "$handwritten_changelogs" write_multiline_output "comment_body" "$comment_body" env: STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }} STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }} STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES: ${{ steps.changed-files.outputs.modified_files }} STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES: ${{ steps.changed-files.outputs.renamed_files }} HEAD_REF: ${{ github.event.pull_request.head.ref }} - name: Find existing changelog comment if: github.event.pull_request.head.repo.full_name == github.repository id: find-comment uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' body-includes: '' - name: Update PR comment with changelog status if: github.event.pull_request.head.repo.full_name == github.repository uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find-comment.outputs.comment-id }} edit-mode: replace body: | ${{ steps.check-folders.outputs.comment_body }} - name: Fail if changelog fragment is missing or invalid if: steps.check-folders.outputs.missing_fragments != '' || steps.check-folders.outputs.invalid_fragments != '' || steps.check-folders.outputs.linked_fragments != '' || steps.check-folders.outputs.handwritten_changelogs != '' run: | echo "::error::Missing, invalid, or disallowed changelog updates" exit 1