diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f2065a3db00..aeb87d3a593 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,8 +18,8 @@ Please add a detailed description of how to review this PR. Community Checklist -- [ ] This feature/issue is listed in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com -- [ ] Is it assigned to me, if not, request it via the issue/feature in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack) +- [ ] This feature/issue is listed in the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com +- [ ] Is it assigned to me, if not, request it via the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](https://goto.prowler.com/slack) @@ -28,7 +28,7 @@ Please add a detailed description of how to review this PR. - [ ] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings - [ ] Review if backport is needed. - [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md) -- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/prowler/CHANGELOG.md), if applicable. +- [ ] Ensure a changelog fragment is added under [prowler/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/prowler/changelog.d), if applicable. #### SDK/CLI - Are there new checks included in this PR? Yes / No @@ -40,7 +40,7 @@ Please add a detailed description of how to review this PR. - [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px) - [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px) - [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px) -- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/ui/CHANGELOG.md), if applicable. +- [ ] Ensure a changelog fragment is added under [ui/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/ui/changelog.d), if applicable. #### API - [ ] All issue/task requirements work as expected on the API @@ -50,7 +50,11 @@ Please add a detailed description of how to review this PR. - [ ] Any other relevant evidence of the implementation (if applicable) - [ ] Verify if API specs need to be regenerated. - [ ] Check if version updates are required (e.g., specs, uv, etc.). -- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable. +- [ ] Ensure a changelog fragment is added under [api/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/api/changelog.d), if applicable. + +#### MCP Server +- [ ] All issue/task requirements work as expected on the MCP Server +- [ ] Ensure a changelog fragment is added under [mcp_server/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/mcp_server/changelog.d), if applicable. ### License diff --git a/.github/scripts/changelog_attribution.py b/.github/scripts/changelog_attribution.py new file mode 100644 index 00000000000..d099e5d2434 --- /dev/null +++ b/.github/scripts/changelog_attribution.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Rename changelog fragments to their PR number before running towncrier. + +For every ..md in /changelog.d/, find the commit that +added it, resolve its PR via the GitHub API (falling back to the squash-commit +subject), and `git mv` it to ..md so towncrier renders the PR link. +Unresolvable fragments become +..md orphans (rendered without link). +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +FRAGMENT_RE = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*?)" + r"\.(?Padded|changed|deprecated|removed|fixed|security)" + r"(?:\.(?P[0-9]+))?\.md$" +) +SUBJECT_PR_RE = re.compile(r" \(#([0-9]+)\)$") +IGNORED_FILES = {".gitkeep", "README.md"} +API_TIMEOUT_SECONDS = 10 + + +def git(*args: str) -> str: + result = subprocess.run(["git", *args], check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def find_adding_commit(path: str) -> str | None: + """Find the commit that added a file, following renames. + + Falls back to a plain (no --follow) lookup: rename detection can lose the + add event for degenerate content (e.g. files identical to many others). + """ + sha = git("log", "--follow", "--diff-filter=A", "--format=%H", "-1", "--", path) + if not sha: + sha = git("log", "--diff-filter=A", "--format=%H", "-1", "--", path) + return sha or None + + +def pr_from_api(repo: str, sha: str) -> int | None: + """Resolve the PR associated with a commit via the GitHub API. + + Returns None on any network/API failure so the caller can fall back to + parsing the squash-commit subject. + """ + url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "prowler-changelog-attribution", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=API_TIMEOUT_SECONDS) as response: + pulls = json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + return None + if isinstance(pulls, list) and pulls: + return pulls[0].get("number") + return None + + +def pr_from_subject(sha: str) -> int | None: + subject = git("log", "-1", "--format=%s", sha) + match = SUBJECT_PR_RE.search(subject) + return int(match.group(1)) if match else None + + +def unique_destination(directory: str, base_name: str, fragment_type: str) -> str: + """Return a non-colliding fragment path, appending a numeric counter if needed.""" + candidate = os.path.join(directory, f"{base_name}.{fragment_type}.md") + counter = 0 + while os.path.exists(candidate): + counter += 1 + candidate = os.path.join(directory, f"{base_name}.{fragment_type}.{counter}.md") + return candidate + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("component_dir", help="Component directory, e.g. prowler") + parser.add_argument("--repo", default="prowler-cloud/prowler") + parser.add_argument( + "--no-api", + action="store_true", + help="Skip the GitHub API and resolve PRs from commit subjects only", + ) + args = parser.parse_args() + + fragments_dir = os.path.join(args.component_dir, "changelog.d") + if not os.path.isdir(fragments_dir): + print(f"::error::Fragments directory not found: {fragments_dir}") + return 1 + + malformed = [] + to_process = [] + for name in sorted(os.listdir(fragments_dir)): + if name in IGNORED_FILES or name.startswith("+"): + continue + match = FRAGMENT_RE.match(name) + if not match: + malformed.append(name) + continue + if match.group("slug").isdigit(): + continue + to_process.append((name, match)) + + if malformed: + for name in malformed: + print( + f"::error::Malformed fragment filename in {fragments_dir}: {name} " + "(expected ..md with type one of added|changed|" + "deprecated|removed|fixed|security)" + ) + return 1 + + for name, match in to_process: + slug, fragment_type = match.group("slug"), match.group("type") + + path = os.path.join(fragments_dir, name) + sha = find_adding_commit(path) + pr_number = None + if sha: + if not args.no_api: + pr_number = pr_from_api(args.repo, sha) + if pr_number is None: + pr_number = pr_from_subject(sha) + + if pr_number is not None: + destination = unique_destination( + fragments_dir, str(pr_number), fragment_type + ) + else: + destination = unique_destination(fragments_dir, f"+{slug}", fragment_type) + print( + f"::warning::Could not resolve a PR for {path}; renamed to " + f"{os.path.basename(destination)} (entry will render without a PR link)" + ) + git("mv", path, destination) + print(f"{path} -> {destination}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/towncrier/template.md.jinja b/.github/towncrier/template.md.jinja new file mode 100644 index 00000000000..5d89185c03f --- /dev/null +++ b/.github/towncrier/template.md.jinja @@ -0,0 +1,13 @@ +{% set category_order = definitions.keys() %} +{% for section, _ in sections.items() %} +{% for category in category_order if category in sections[section] %} +### {{ definitions[category]['name'] }} + +{% for text, values in sections[section][category].items() -%} +- {{ text }}{% if values %} {{ values|join(', ') }}{% endif %}{{ "\n" }} +{%- endfor %} + +{% endfor %} +{% endfor %} +--- +{{ "\n" }} diff --git a/.github/workflows/api-code-quality.yml b/.github/workflows/api-code-quality.yml index 362f0dbbbfe..93e8d1006d3 100644 --- a/.github/workflows/api-code-quality.yml +++ b/.github/workflows/api-code-quality.yml @@ -62,6 +62,7 @@ jobs: api/docs/** api/README.md api/CHANGELOG.md + api/changelog.d/** api/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index d12bf5d2d4c..b6881f93f23 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -108,6 +108,7 @@ jobs: api/docs/** api/README.md api/CHANGELOG.md + api/changelog.d/** api/AGENTS.md - name: Set up Docker Buildx diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index ed4e5476d2d..d687ff0d309 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -77,6 +77,7 @@ jobs: api/docs/** api/README.md api/CHANGELOG.md + api/changelog.d/** api/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index bbb20cdadcf..54731394140 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -111,6 +111,7 @@ jobs: api/docs/** api/README.md api/CHANGELOG.md + api/changelog.d/** api/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/compile-changelogs.yml b/.github/workflows/compile-changelogs.yml new file mode 100644 index 00000000000..b7e1b4fb935 --- /dev/null +++ b/.github/workflows/compile-changelogs.yml @@ -0,0 +1,478 @@ +name: 'Tools: Compile Changelogs' + +run-name: 'Compile changelogs for Prowler ${{ inputs.prowler_version }}' + +on: + workflow_dispatch: + inputs: + prowler_version: + description: 'Prowler version being released (e.g., 5.31.0)' + required: true + type: string + target_branch: + description: 'Branch to compile on (master for minor releases, v5.X for patches)' + required: true + type: string + sdk_version: + description: 'SDK version override (empty = auto-derive from prowler/CHANGELOG.md + pending fragment types; "skip" = hold this component back)' + required: false + type: string + api_version: + description: 'API version override (empty = auto-derive; "skip" = hold back)' + required: false + type: string + ui_version: + description: 'UI version override (empty = auto-derive; "skip" = hold back)' + required: false + type: string + mcp_version: + description: 'MCP Server version override (empty = auto-derive; "skip" = hold back)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ inputs.prowler_version }} + cancel-in-progress: false + +env: + PROWLER_VERSION: ${{ inputs.prowler_version }} + TARGET_BRANCH: ${{ inputs.target_branch }} + SDK_VERSION: ${{ inputs.sdk_version }} + API_VERSION: ${{ inputs.api_version }} + UI_VERSION: ${{ inputs.ui_version }} + MCP_VERSION: ${{ inputs.mcp_version }} + +permissions: {} + +jobs: + compile-changelogs: + if: github.event_name == 'workflow_dispatch' && github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + steps: + - name: Harden the runner (Block outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 # PR attribution resolves each fragment's adding commit from history + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.12' + + - name: Install towncrier + run: pip install --no-cache-dir towncrier==25.8.0 + + - name: Configure Git + run: | + git config --global user.name 'prowler-bot' + git config --global user.email '179230569+prowler-bot@users.noreply.github.com' + + - name: Validate version inputs + run: | + if [[ ! "$PROWLER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid prowler_version syntax: '$PROWLER_VERSION' (must be N.N.N)" + exit 1 + fi + if [ "$TARGET_BRANCH" != "master" ] && [[ ! "$TARGET_BRANCH" =~ ^v[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid target_branch syntax: '$TARGET_BRANCH' (must be 'master' or vN.N, e.g. v5.31)" + exit 1 + fi + IFS=. read -r prowler_major prowler_minor prowler_patch <<< "$PROWLER_VERSION" + prowler_major=$((10#$prowler_major)) + prowler_minor=$((10#$prowler_minor)) + prowler_patch=$((10#$prowler_patch)) + if [ "$prowler_patch" -eq 0 ]; then + if [ "$TARGET_BRANCH" != "master" ]; then + echo "::error::target_branch must be 'master' for Prowler ${PROWLER_VERSION}; got '${TARGET_BRANCH}'" + exit 1 + fi + else + expected_target_branch="v${prowler_major}.${prowler_minor}" + if [ "$TARGET_BRANCH" != "$expected_target_branch" ]; then + echo "::error::target_branch must be '${expected_target_branch}' for Prowler ${PROWLER_VERSION}; got '${TARGET_BRANCH}'" + exit 1 + fi + fi + for pair in "sdk_version:$SDK_VERSION" "api_version:$API_VERSION" "ui_version:$UI_VERSION" "mcp_version:$MCP_VERSION"; do + input_name="${pair%%:*}" + input_value="${pair#*:}" + if [ -n "$input_value" ] && [ "$input_value" != "skip" ] && [[ ! "$input_value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid $input_name syntax: '$input_value' (must be N.N.N, empty for auto-derivation, or 'skip')" + exit 1 + fi + done + + - name: Compile changelogs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + component_input() { + case "$1" in + prowler) echo "$SDK_VERSION" ;; + api) echo "$API_VERSION" ;; + ui) echo "$UI_VERSION" ;; + mcp_server) echo "$MCP_VERSION" ;; + esac + } + + version_key() { + local version="$1" + local major minor patch + IFS=. read -r major minor patch <<< "$version" + printf '%06d.%06d.%06d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))" + } + + pending_fragments() { + find "$1/changelog.d" -maxdepth 1 -type f ! -name '.gitkeep' ! -name 'README.md' | sort + } + + # The component's last released version is the first stamped heading + # of its CHANGELOG.md, the same source prepare-release.yml greps. + latest_released_version() { + grep -m1 -E '^## \[v?[0-9]+\.[0-9]+\.[0-9]+\]' "$1/CHANGELOG.md" | sed -E 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/' + } + + has_removed_fragments() { + echo "$1" | grep -qE '\.removed(\.[0-9]+)?\.md$' + } + + # Resolve every component's effective version before compiling + # anything, so a wrong input cannot leave the tree half-compiled. + # Empty input = auto-derive (latest released version + semver bump + # from the pending fragment types). 'skip' = hold the component back. + errors=0 + compiling="" + for component in prowler api ui mcp_server; do + input=$(component_input "$component") + fragments=$(pending_fragments "$component") + + if [ "$input" = "skip" ]; then + if [ -n "$fragments" ]; then + echo "::warning::${component}: held back by request; these pending fragments stay for a future release:" + echo "$fragments" + fi + continue + fi + if [ -n "$input" ] && [ -z "$fragments" ]; then + echo "::error::${component}: version input '$input' provided but ${component}/changelog.d/ has no pending fragments (wrong input?)" + errors=1 + continue + fi + if [ -z "$fragments" ]; then + continue + fi + + removed_fragments=false + if has_removed_fragments "$fragments"; then + removed_fragments=true + fi + current=$(latest_released_version "$component") + if [ -z "$current" ]; then + echo "::error::${component}: could not read the latest released version from ${component}/CHANGELOG.md; restore the released heading before compiling" + errors=1 + continue + fi + + if [ -n "$input" ]; then + effective="$input" + mode="explicit" + current_key=$(version_key "$current") + effective_key=$(version_key "$effective") + if [[ "$effective_key" < "$current_key" || "$effective_key" == "$current_key" ]]; then + echo "::error::${component}: explicit version '${effective}' must be greater than the latest released version (${current})" + errors=1 + continue + fi + else + if [ "$removed_fragments" = "true" ]; then + echo "::error::${component}: pending 'removed' fragments imply a major bump (breaking change); provide its version input explicitly" + errors=1 + continue + fi + IFS=. read -r major minor patch <<< "$current" + major=$((10#$major)) + minor=$((10#$minor)) + patch=$((10#$patch)) + if echo "$fragments" | grep -qE '\.(added|changed|deprecated)(\.[0-9]+)?\.md$'; then + effective="${major}.$((minor + 1)).0" + else + effective="${major}.${minor}.$((patch + 1))" + fi + mode="auto" + echo "::notice::${component}: version auto-derived ${current} -> ${effective} from the pending fragment types" + fi + + if [ "$removed_fragments" = "true" ]; then + IFS=. read -r current_major _ <<< "$current" + current_major=$((10#$current_major)) + IFS=. read -r effective_major effective_minor effective_patch <<< "$effective" + effective_major=$((10#$effective_major)) + effective_minor=$((10#$effective_minor)) + effective_patch=$((10#$effective_patch)) + if [ "$effective_major" -le "$current_major" ] || [ "$effective_minor" -ne 0 ] || [ "$effective_patch" -ne 0 ]; then + echo "::error::${component}: removed fragments require a major component release (${current} -> X.0.0 with X > ${current_major}); got ${effective}" + errors=1 + continue + fi + fi + + # Without the marker the build would insert the new block above the + # file header instead of below it. + if ! grep -q '^$' "$component/CHANGELOG.md"; then + echo "::error::${component}/CHANGELOG.md is missing the '' marker; restore it after the intro line before compiling" + errors=1 + continue + fi + # A hand-written UNRELEASED block means someone followed the old + # convention; its entries would be left out of the compiled block + # and out of the release notes extraction. + if grep -q '(Prowler UNRELEASED)' "$component/CHANGELOG.md"; then + echo "::error::${component}/CHANGELOG.md contains a hand-written '(Prowler UNRELEASED)' block; convert its entries to fragments in ${component}/changelog.d/ and delete the block before compiling" + errors=1 + continue + fi + + echo "${effective} ${mode}" > "${RUNNER_TEMP}/version-${component}.txt" + compiling="${compiling}${component} " + done + if [ "$errors" -ne 0 ]; then + exit 1 + fi + if [ -z "$compiling" ]; then + echo "::error::Nothing to compile: no component has pending fragments to release" + exit 1 + fi + + body_file="${RUNNER_TEMP}/compile-changelogs-pr-body.md" + { + echo "### Description" + echo "" + echo "Compiles the pending changelog fragments into the per-component \`CHANGELOG.md\` files for Prowler v${PROWLER_VERSION}, replacing the manual stamping PR." + echo "" + echo "| Component | Version | Fragments consumed |" + echo "|---|---|---|" + } > "$body_file" + + compiled_components="" + for component in prowler api ui mcp_server; do + if [ ! -f "${RUNNER_TEMP}/version-${component}.txt" ]; then + echo "Skipping ${component} (no pending fragments or held back)" + echo "| \`${component}\` | - | 0 |" >> "$body_file" + continue + fi + read -r version mode < "${RUNNER_TEMP}/version-${component}.txt" + version_label="$version" + if [ "$mode" = "auto" ]; then + version_label="${version} (auto)" + fi + + count=$(pending_fragments "$component" | wc -l | tr -d ' ') + echo "Compiling ${component} ${version} (${count} fragments, ${mode} version)..." + + # Captured before attribution renames them: these original paths are + # what the forward-sync deletes on master (backports copy fragments + # verbatim, so filenames match across branches). + pending_fragments "$component" > "${RUNNER_TEMP}/consumed-${component}.txt" + pre_lines=$(wc -l < "$component/CHANGELOG.md") + + # Attribution must run before the build: towncrier renders the + # first dotted segment of each filename as the PR number. + python .github/scripts/changelog_attribution.py "$component" + towncrier build --config "$component/towncrier.toml" --version "$version" --name "Prowler v${PROWLER_VERSION}" --yes + + # The build only inserts lines right after the marker, so the new + # stamped block is exactly the added lines following it. Captured + # for the forward-sync to master. + post_lines=$(wc -l < "$component/CHANGELOG.md") + delta=$((post_lines - pre_lines)) + marker_line=$(grep -n -m1 '^$' "$component/CHANGELOG.md" | cut -d: -f1) + sed -n "$((marker_line + 1)),$((marker_line + delta))p" "$component/CHANGELOG.md" > "${RUNNER_TEMP}/block-${component}.md" + + compiled_components="${compiled_components}${component} " + echo "| \`${component}\` | ${version_label} | ${count} |" >> "$body_file" + done + echo "COMPILED_COMPONENTS=${compiled_components}" >> "$GITHUB_ENV" + + { + echo "" + echo "Review that no pending fragment was dropped (the diff must delete every consumed fragment) and that each new version block is correct, then squash-merge." + echo "" + echo "### License" + echo "" + echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license." + } >> "$body_file" + + echo "PR_BODY_FILE=${body_file}" >> "$GITHUB_ENV" + + - name: Create compile PR + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }}' + branch: compile-changelogs-${{ env.PROWLER_VERSION }} + base: ${{ env.TARGET_BRANCH }} + title: 'chore(changelog): v${{ env.PROWLER_VERSION }}' + body-path: ${{ env.PR_BODY_FILE }} + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + labels: | + no-changelog + + # Patch compiles (target_branch = v5.X) leave master holding the consumed + # fragments and missing the new version block. This applies the equivalent + # change to master: insert the same stamped blocks under the marker and + # delete the consumed fragments, so the next minor compile cannot + # re-release entries that already shipped in the patch. + - name: Apply forward-sync to master + if: env.TARGET_BRANCH != 'master' + run: | + set -euo pipefail + + git checkout -B master origin/master + + version_key() { + local version="$1" + local major minor patch + IFS=. read -r major minor patch <<< "$version" + printf '%06d.%06d.%06d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))" + } + + release_from_heading() { + local heading="$1" + echo "$heading" | sed -E 's/^## \[[^]]+\] \(Prowler v?([0-9]+\.[0-9]+\.[0-9]+)\).*/\1/' + } + + insert_changelog_block_ordered() { + local component="$1" + local block_file="$2" + local changelog="${component}/CHANGELOG.md" + local incoming_heading incoming_release incoming_key + local marker_line insertion_line duplicate_line + local line heading existing_release existing_key + + marker_line=$(grep -n -m1 '^$' "$changelog" | cut -d: -f1) + incoming_heading=$(grep -m1 -E '^## \[[^]]+\] \(Prowler v?[0-9]+\.[0-9]+\.[0-9]+\)' "$block_file" || true) + if [ -z "$incoming_heading" ]; then + echo "::error::${block_file} does not contain a stamped Prowler release heading" + exit 1 + fi + + incoming_release=$(release_from_heading "$incoming_heading") + incoming_key=$(version_key "$incoming_release") + insertion_line="" + duplicate_line="" + + while IFS=: read -r line heading; do + existing_release=$(release_from_heading "$heading") + existing_key=$(version_key "$existing_release") + if [[ "$incoming_key" == "$existing_key" ]]; then + duplicate_line="$line" + break + fi + if [[ "$incoming_key" > "$existing_key" ]]; then + insertion_line="$line" + break + fi + done < <(grep -n -E '^## \[[^]]+\] \(Prowler v?[0-9]+\.[0-9]+\.[0-9]+\)' "$changelog" || true) + + if [ -n "$duplicate_line" ]; then + echo "::error::${changelog} already contains a block for Prowler v${incoming_release} at line ${duplicate_line}; refusing to insert a duplicate" + exit 1 + fi + if [ -z "$insertion_line" ]; then + insertion_line=$(($(wc -l < "$changelog") + 1)) + fi + if [ "$insertion_line" -le "$marker_line" ]; then + insertion_line=$((marker_line + 1)) + fi + + { + head -n "$((insertion_line - 1))" "$changelog" + cat "$block_file" + tail -n +"$insertion_line" "$changelog" + } > "${RUNNER_TEMP}/changelog.tmp" + mv "${RUNNER_TEMP}/changelog.tmp" "$changelog" + + echo "::notice::Inserted ${component} changelog block for Prowler v${incoming_release} at line ${insertion_line}" + } + + sync_body="${RUNNER_TEMP}/forward-sync-pr-body.md" + { + echo "### Description" + echo "" + echo "Forward-syncs the v${PROWLER_VERSION} compiled changelogs from \`${TARGET_BRANCH}\` to \`master\`: inserts the same stamped version blocks under the insertion marker and deletes the consumed fragments, so the next minor compile cannot re-release entries that already shipped in this patch. Opened automatically by the same run that opened the compile PR; review and squash-merge after it." + echo "" + echo "| Component | Fragments deleted on master | Skipped (only on ${TARGET_BRANCH}) |" + echo "|---|---|---|" + } > "$sync_body" + + for component in $COMPILED_COMPONENTS; do + block_file="${RUNNER_TEMP}/block-${component}.md" + consumed_file="${RUNNER_TEMP}/consumed-${component}.txt" + + if ! grep -qm1 '^$' "$component/CHANGELOG.md"; then + echo "::error::${component}/CHANGELOG.md on master is missing the insertion marker; cannot forward-sync" + exit 1 + fi + + deleted=0 + skipped=0 + while IFS= read -r fragment; do + if [ -z "$fragment" ]; then + continue + fi + if [ -f "$fragment" ]; then + git rm -q "$fragment" + deleted=$((deleted + 1)) + else + echo "::notice::${fragment} does not exist on master (change landed only on ${TARGET_BRANCH}); skipping its deletion" + skipped=$((skipped + 1)) + fi + done < "$consumed_file" + + insert_changelog_block_ordered "$component" "$block_file" + + echo "| \`${component}\` | ${deleted} | ${skipped} |" >> "$sync_body" + done + + { + echo "" + echo "### License" + echo "" + echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license." + } >> "$sync_body" + + echo "SYNC_BODY_FILE=${sync_body}" >> "$GITHUB_ENV" + + - name: Create forward-sync PR + if: env.TARGET_BRANCH != 'master' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master' + branch: forward-sync-changelogs-${{ env.PROWLER_VERSION }} + base: master + title: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master' + body-path: ${{ env.SYNC_BODY_FILE }} + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + labels: | + no-changelog diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 23e6ba29845..1f47c31189c 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -102,6 +102,7 @@ jobs: files_ignore: | mcp_server/README.md mcp_server/CHANGELOG.md + mcp_server/changelog.d/** - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index 0b9bc205523..82f090d4bee 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -19,6 +19,60 @@ concurrency: 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@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.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 @@ -62,53 +116,160 @@ jobs: uv.lock pyproject.toml - - name: Check for folder changes and changelog presence + - name: Check for folder changes and changelog fragment presence id: check-folders run: | - missing_changelogs="" + 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=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_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 - # Get files changed in this folder - changed_in_folder=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^${folder}/" || true) + 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}/" - # Check if CHANGELOG.md was updated - if ! echo "$changed_in_folder" | grep -q "^${folder}/CHANGELOG.md$"; then - echo "No changelog update found for ${folder}/" - missing_changelogs="${missing_changelogs}- \`${folder}\`"$'\n' + 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 "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(uv\.lock|pyproject\.toml)$" || true) + 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" - # Check if prowler/CHANGELOG.md was already updated (might have been caught above) - prowler_changelog_updated=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true) - if [ -z "$prowler_changelog_updated" ]; then + if ! has_changelog_update "prowler"; then # Only add if prowler wasn't already flagged - if ! echo "$missing_changelogs" | grep -q "prowler"; then - echo "No changelog update found for root dependency changes" - missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n' + 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 - { - echo "missing_changelogs<> $GITHUB_OUTPUT + 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 @@ -128,14 +289,10 @@ jobs: edit-mode: replace body: | - ${{ steps.check-folders.outputs.missing_changelogs != '' && format('⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:** - - {0} - - Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes.', steps.check-folders.outputs.missing_changelogs) || '✅ All necessary `CHANGELOG.md` files have been updated.' }} + ${{ steps.check-folders.outputs.comment_body }} - - name: Fail if changelog is missing - if: steps.check-folders.outputs.missing_changelogs != '' + - 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 changelog updates in some folders" + echo "::error::Missing, invalid, or disallowed changelog updates" exit 1 diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index 2b1efc69a99..289508ce7d1 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -55,6 +55,7 @@ jobs: files_ignore: | .github/** prowler/CHANGELOG.md + prowler/changelog.d/** docs/** permissions/** api/** diff --git a/.github/workflows/sdk-codeql.yml b/.github/workflows/sdk-codeql.yml index 0696f7c6a55..a86d6dcc53d 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -12,6 +12,7 @@ on: - '.github/workflows/sdk-codeql.yml' - '.github/codeql/sdk-codeql-config.yml' - '!prowler/CHANGELOG.md' + - '!prowler/changelog.d/**' pull_request: branches: - 'master' @@ -23,6 +24,7 @@ on: - '.github/workflows/sdk-codeql.yml' - '.github/codeql/sdk-codeql-config.yml' - '!prowler/CHANGELOG.md' + - '!prowler/changelog.d/**' schedule: - cron: '00 12 * * *' diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 2b436db4688..b4821a2fe7b 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -113,6 +113,7 @@ jobs: .github/workflows/sdk-container-checks.yml files_ignore: | prowler/CHANGELOG.md + prowler/changelog.d/** **/AGENTS.md - name: Set up Docker Buildx diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index 11a7894ce8d..192ef6279a0 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -73,6 +73,7 @@ jobs: .github/scripts/osv-scan.sh files_ignore: | prowler/CHANGELOG.md + prowler/changelog.d/** **/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 4fc6cab0b7e..2fe6b1d5364 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -77,6 +77,7 @@ jobs: files_ignore: | .github/** prowler/CHANGELOG.md + prowler/changelog.d/** docs/** permissions/** api/** diff --git a/.github/workflows/ui-codeql.yml b/.github/workflows/ui-codeql.yml index 3dcb4f42eb1..41b18105911 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -10,6 +10,7 @@ on: - '.github/workflows/ui-codeql.yml' - '.github/codeql/ui-codeql-config.yml' - '!ui/CHANGELOG.md' + - '!ui/changelog.d/**' pull_request: branches: - 'master' @@ -19,6 +20,7 @@ on: - '.github/workflows/ui-codeql.yml' - '.github/codeql/ui-codeql-config.yml' - '!ui/CHANGELOG.md' + - '!ui/changelog.d/**' schedule: - cron: '00 12 * * *' diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index a931e50f223..96b3315bc0e 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -102,6 +102,7 @@ jobs: files: ui/** files_ignore: | ui/CHANGELOG.md + ui/changelog.d/** ui/README.md ui/AGENTS.md diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index f406b24e812..5e7e7517bbb 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -64,6 +64,7 @@ jobs: .github/workflows/ui-tests.yml files_ignore: | ui/CHANGELOG.md + ui/changelog.d/** ui/README.md ui/AGENTS.md diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8f6b837ea33..5c917d3198e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to the **Prowler API** are documented in this file. + + ## [1.34.0] (Prowler v5.33.0) ### 🚀 Added diff --git a/api/changelog.d/.gitkeep b/api/changelog.d/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/changelog.d/README.md b/api/changelog.d/README.md new file mode 100644 index 00000000000..0853174f309 --- /dev/null +++ b/api/changelog.d/README.md @@ -0,0 +1,10 @@ +# Changelog fragments + +Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict. + +- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`) +- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security` +- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time) +- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs + +Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`. diff --git a/api/towncrier.toml b/api/towncrier.toml new file mode 100644 index 00000000000..528e6129ece --- /dev/null +++ b/api/towncrier.toml @@ -0,0 +1,39 @@ +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +title_format = "## [{version}] ({name})" +issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})" +template = "../.github/towncrier/template.md.jinja" +underlines = ["", "", ""] +ignore = [".gitkeep", "README.md"] + +[[tool.towncrier.type]] +directory = "added" +name = "🚀 Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "🔄 Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "⚠️ Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "❌ Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "🐞 Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "security" +name = "🔐 Security" +showcontent = true diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index d3e7d3631f2..138cb658772 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -3421,7 +3421,7 @@ Use existing providers as templates, this will help you to understand better the - **Documentation & Maintenance** - **README Updates**: Update provider-specific documentation - - **Changelog**: Document changes and new features + - **Changelog**: Document changes and new features with a fragment under `prowler/changelog.d/` (see the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md)) - **Examples**: Provide usage examples and common scenarios - **Troubleshooting**: Include common issues and solutions - **Documentation**: Update the provider documentation to include your new tool provider in the examples and implementation guidance. diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 75392ed3a79..d80588dd242 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -778,7 +778,7 @@ Before opening the pull request: uv run pre-commit run --all-files uv run pytest -n auto ``` -2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers. +2. Add a changelog fragment `prowler/changelog.d/.added.md`, describing the new framework and the providers it covers (no PR link in the text; it is attached automatically at release time). 3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`. 4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`. diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index 8f1438a3bbc..ec0a2e354f4 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to the **Prowler MCP Server** are documented in this file. + + ## [0.7.2] (Prowler v5.28.1) ### 🐞 Fixed diff --git a/mcp_server/changelog.d/.gitkeep b/mcp_server/changelog.d/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/mcp_server/changelog.d/README.md b/mcp_server/changelog.d/README.md new file mode 100644 index 00000000000..0853174f309 --- /dev/null +++ b/mcp_server/changelog.d/README.md @@ -0,0 +1,10 @@ +# Changelog fragments + +Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict. + +- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`) +- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security` +- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time) +- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs + +Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`. diff --git a/mcp_server/towncrier.toml b/mcp_server/towncrier.toml new file mode 100644 index 00000000000..528e6129ece --- /dev/null +++ b/mcp_server/towncrier.toml @@ -0,0 +1,39 @@ +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +title_format = "## [{version}] ({name})" +issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})" +template = "../.github/towncrier/template.md.jinja" +underlines = ["", "", ""] +ignore = [".gitkeep", "README.md"] + +[[tool.towncrier.type]] +directory = "added" +name = "🚀 Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "🔄 Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "⚠️ Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "❌ Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "🐞 Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "security" +name = "🔐 Security" +showcontent = true diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f73c1955a37..7a98933c5ff 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to the **Prowler SDK** are documented in this file. + + ## [5.33.0] (Prowler v5.33.0) ### 🐞 Fixed diff --git a/prowler/changelog.d/.gitkeep b/prowler/changelog.d/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/changelog.d/11572.added.md b/prowler/changelog.d/11572.added.md new file mode 100644 index 00000000000..4838752200a --- /dev/null +++ b/prowler/changelog.d/11572.added.md @@ -0,0 +1 @@ +Changelog fragment workflow for SDK, API, UI, and MCP Server releases, including PR attribution, fragment validation, release compilation, and preserved section ordering diff --git a/prowler/changelog.d/README.md b/prowler/changelog.d/README.md new file mode 100644 index 00000000000..0853174f309 --- /dev/null +++ b/prowler/changelog.d/README.md @@ -0,0 +1,10 @@ +# Changelog fragments + +Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict. + +- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`) +- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security` +- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time) +- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs + +Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`. diff --git a/prowler/towncrier.toml b/prowler/towncrier.toml new file mode 100644 index 00000000000..528e6129ece --- /dev/null +++ b/prowler/towncrier.toml @@ -0,0 +1,39 @@ +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +title_format = "## [{version}] ({name})" +issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})" +template = "../.github/towncrier/template.md.jinja" +underlines = ["", "", ""] +ignore = [".gitkeep", "README.md"] + +[[tool.towncrier.type]] +directory = "added" +name = "🚀 Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "🔄 Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "⚠️ Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "❌ Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "🐞 Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "security" +name = "🔐 Security" +showcontent = true diff --git a/skills/prowler-changelog/SKILL.md b/skills/prowler-changelog/SKILL.md index 67e853b0fdf..369da2ba55b 100644 --- a/skills/prowler-changelog/SKILL.md +++ b/skills/prowler-changelog/SKILL.md @@ -6,7 +6,7 @@ description: > license: Apache-2.0 metadata: author: prowler-cloud - version: "1.0" + version: "2.0" scope: [root, ui, api, sdk, mcp_server] auto_invoke: - "Add changelog entry for a PR or feature" @@ -16,142 +16,109 @@ metadata: allowed-tools: Read, Edit, Write, Glob, Grep, Bash --- -## Changelog Locations +## How changelog entries work: fragments -| Component | File | Version Prefix | Current Version | -|-----------|------|----------------|-----------------| -| UI | `ui/CHANGELOG.md` | None | 1.x.x | -| API | `api/CHANGELOG.md` | None | 1.x.x | -| MCP Server | `mcp_server/CHANGELOG.md` | None | 0.x.x | -| SDK | `prowler/CHANGELOG.md` | None | 5.x.x | +A PR never edits unreleased `CHANGELOG.md` content directly; use fragments instead. Released-block typo/correction fixes are the only direct-edit exception and are described below. For regular entries, add one small **fragment file** per entry under the component's `changelog.d/` directory. Fragments are compiled into the component's `CHANGELOG.md` at release time (deleting the consumed fragments), so concurrent PRs never conflict on the changelog. -## Format Rules (keepachangelog.com) +| Component | Fragments directory | Compiled file | +|-----------|---------------------|---------------| +| UI | `ui/changelog.d/` | `ui/CHANGELOG.md` | +| API | `api/changelog.d/` | `api/CHANGELOG.md` | +| MCP Server | `mcp_server/changelog.d/` | `mcp_server/CHANGELOG.md` | +| SDK | `prowler/changelog.d/` | `prowler/CHANGELOG.md` | -### Section Order (ALWAYS this order) +"What's unreleased" = "what's in `changelog.d/`". The compiled `CHANGELOG.md` files contain only released versions. -```markdown -## [X.Y.Z] (Prowler vA.B.C) OR (Prowler UNRELEASED) - -### Added -### Changed -### Deprecated -### Removed -### Fixed -### Security -``` +## Fragment filename -### Emoji Prefixes (REQUIRED for ALL components) +```text +..md +``` -| Section | Emoji | Usage | -|---------|-------|-------| -| Added | `### 🚀 Added` | New features, checks, endpoints | -| Changed | `### 🔄 Changed` | Modifications to existing functionality | -| Deprecated | `### ⚠️ Deprecated` | Features marked for removal | -| Removed | `### ❌ Removed` | Deleted features | -| Fixed | `### 🐞 Fixed` | Bug fixes | -| Security | `### 🔐 Security` | Security patches, CVE fixes | +- `` is free-form (`[A-Za-z0-9][A-Za-z0-9._-]*`), chosen by the author, ideally descriptive of the change (e.g. `securityhub-delegated-admin`). The PR number is also a valid slug (e.g. `11259`) when it is already known; it is never required. +- `` maps 1:1 to the keepachangelog sections: -### Entry Format +| `` | Section | Usage | +|----------|---------|-------| +| `added` | `### 🚀 Added` | New features, checks, endpoints | +| `changed` | `### 🔄 Changed` | Modifications to existing functionality | +| `deprecated` | `### ⚠️ Deprecated` | Features marked for removal | +| `removed` | `### ❌ Removed` | Deleted features | +| `fixed` | `### 🐞 Fixed` | Bug fixes | +| `security` | `### 🔐 Security` | Security patches, CVE fixes | -```markdown -### Added +- A PR adds as many fragment files as entries it needs, freely mixing types: one file per entry. E.g. a PR touching Added, Changed and Fixed ships `kms-rotation-check.added.md` + `kms-metadata-cache.changed.md` + `kms-disabled-keys.fixed.md`, and all compile with the same PR link into their own sections. +- Several entries of the SAME type: a different slug per entry (`kms-rotation-check.added.md`, `kms-rotation-docs.added.md`). +- At least one fragment per touched component, same as the old one-entry-per-changelog rule. -- Existing entry one [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- Existing entry two [(#YYYY)](https://github.com/prowler-cloud/prowler/pull/YYYY) -- NEW ENTRY GOES HERE at the BOTTOM [(#ZZZZ)](https://github.com/prowler-cloud/prowler/pull/ZZZZ) +## Fragment content -### Changed +The file contains ONLY the entry text, exactly as it should appear in the changelog, on a single line ending with a trailing newline: -- Existing change [(#AAAA)](https://github.com/prowler-cloud/prowler/pull/AAAA) -- NEW CHANGE ENTRY at BOTTOM [(#BBBB)](https://github.com/prowler-cloud/prowler/pull/BBBB) +```bash +echo '`securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on' > prowler/changelog.d/securityhub-delegated-admin.added.md ``` -**Rules:** -- **ADD NEW ENTRIES AT THE BOTTOM of each section** (before next section header or `---`) -- **Blank line after section header** before first entry -- **Blank line between sections** +**Rules (same prose conventions as always):** + +- **NEVER write the PR link in the text.** It is attached automatically at compile time (the compile workflow resolves the PR that added the fragment from git history). Writing `[(#NNNN)](...)` in a fragment produces a duplicated link. +- No period at the end +- Do NOT start with redundant verbs (the section header already provides the action) - Be specific: what changed, not why (that's in the PR) - Keep entries readable: use spaces around inline code and product names, and wrap endpoints, commands, errors, task names, and file paths in backticks - Avoid long run-on sentences; split complex changes into one concise result plus one concise context clause -- One entry per PR (can link multiple PRs for related changes) -- No period at the end -- Do NOT start with redundant verbs (section header already provides the action) -- **CRITICAL: Preserve section order** — when adding a new section to the UNRELEASED block, insert it in the correct position relative to existing sections (Added → Changed → Deprecated → Removed → Fixed → Security). Never append a new section at the top or bottom without checking order -- **CRITICAL: ALWAYS link to the PR, NEVER to the issue.** Every entry MUST use `https://github.com/prowler-cloud/prowler/pull/N`. Linking to `/issues/N` is FORBIDDEN, even when the PR fixes an issue. The issue↔PR relationship belongs in the PR body (`Fixes #N`), not in the changelog. If a fix has no PR yet, do not add the entry until the PR exists. -### Semantic Versioning Rules - -Prowler follows [semver.org](https://semver.org/): - -| Change Type | Version Bump | Example | -|-------------|--------------|---------| -| Bug fixes, patches | PATCH (x.y.**Z**) | 1.16.1 → 1.16.2 | -| New features (backwards compatible) | MINOR (x.**Y**.0) | 1.16.2 → 1.17.0 | -| Breaking changes, removals | MAJOR (**X**.0.0) | 1.17.0 → 2.0.0 | +### Good fragments -**CRITICAL:** `### ❌ Removed` entries MUST only appear in MAJOR version releases. Removing features is a breaking change. +```text +# ui/changelog.d/provider-search-bar.added.md +Search bar when adding a provider -### Released Versions Are Immutable +# api/changelog.d/scan-dispatch-race.fixed.md +`POST /api/v1/scans` no longer intermittently fails with `Scan matching query does not exist`; scan dispatch now publishes the `scan-perform` Celery task after the transaction commits -**NEVER modify already released versions.** Once a version is released (has a Prowler version tag like `v5.16.0`), its changelog section is frozen. - -**Common issue:** A PR is created during release cycle X, includes a changelog entry, but merges after release. The entry is now in the wrong section. - -```markdown -## [1.16.0] (Prowler v5.16.0) ← RELEASED, DO NOT MODIFY - -### Added -- Feature from merged PR [(#9999)] ← WRONG! PR merged after release - -## [1.17.0] (Prowler UNRELEASED) ← Move entry HERE +# ui/changelog.d/node-24-bump.security.md +Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs ``` -**Fix:** Move the entry from the released version to the UNRELEASED section. - -### Version Header Format - -```markdown -## [1.17.0] (Prowler UNRELEASED) # For unreleased changes -## [1.16.0] (Prowler v5.16.0) # For released versions +### Bad fragments ---- # Horizontal rule between versions +```text +Fixed bug. # Too vague, has period, redundant verb +Add search bar # Redundant verb (the section already says "Added") +Search bar [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) # NEVER include the PR link; it is added at compile time ``` -## Mandatory Changelog Preflight +## Semantic Versioning Rules -Before editing any `CHANGELOG.md`, always inspect the active release boundary: +Prowler follows [semver.org](https://semver.org/): -1. Read the UNRELEASED block plus the latest three released version blocks: - ```bash - awk '/^## \[/{n++} n<=4 {print}' ui/CHANGELOG.md - ``` -2. Identify the **only writable block**: the block whose header contains `(Prowler UNRELEASED)`. -3. Treat every block whose header contains `(Prowler vX.Y.Z)` as immutable. Do not add, move, reword, reorder, or deduplicate entries there. -4. If your PR's entry appears in any of the latest three released blocks, remove it from the released block and add it to the correct section in the UNRELEASED block. -5. If there is no UNRELEASED block at the top, stop and ask before editing. +| Change Type | Version Bump | Example | +|-------------|--------------|---------| +| Bug fixes, patches | PATCH (x.y.**Z**) | 1.16.1 → 1.16.2 | +| New features (backwards compatible) | MINOR (x.**Y**.0) | 1.16.2 → 1.17.0 | +| Breaking changes, removals | MAJOR (**X**.0.0) | 1.17.0 → 2.0.0 | -**Do not trust the current topmost matching section name.** A released block can contain the same section heading (`### 🚀 Added`, `### 🔄 Changed`, etc.). Always anchor edits to the `Prowler UNRELEASED` version block first. +**CRITICAL:** `removed` fragments MUST only ship in MAJOR version releases. Removing features is a breaking change. ## Mandatory Human Confirmation Gate -Before creating or editing any changelog file (`CHANGELOG.md`), the agent MUST stop and get explicit user confirmation. This applies even when the changelog gate is failing, the required edit seems obvious, or the user asked to "fix the changelog". +Before creating or editing any changelog fragment or `CHANGELOG.md` file, the agent MUST stop and get explicit user confirmation. This applies even when the changelog gate is failing, the required file seems obvious, or the user asked to "fix the changelog". -Present the proposed changelog action before writing: +Present the proposed action before writing: -1. Target file path. -2. Target version block and section. -3. Exact entry to add, move, remove, or rewrite. -4. Reason the changelog is needed. +1. Target fragment path (component, slug, type) or CHANGELOG.md edit. +2. Exact entry text. +3. Reason the changelog entry is needed. -Only proceed after an explicit approval such as "confirm", "approved", "sí", or equivalent. If the user rejects or does not answer, do not edit or create the changelog. Offer alternatives such as adding `no-changelog` when appropriate. +Only proceed after an explicit approval such as "confirm", "approved", "sí", or equivalent. If the user rejects or does not answer, do not create or edit anything. Offer alternatives such as adding `no-changelog` when appropriate. ## Adding a Changelog Entry ### Step 1: Determine Affected Component(s) ```bash -# Check which files changed -git diff main...HEAD --name-only +git diff master...HEAD --name-only | grep -E '^(ui|api|mcp_server|prowler)/' | cut -d/ -f1 | sort -u ``` | Path Pattern | Component | @@ -160,113 +127,62 @@ git diff main...HEAD --name-only | `api/**` | API | | `mcp_server/**` | MCP Server | | `prowler/**` | SDK | -| Multiple | Update ALL affected changelogs | - -### Step 2: Determine Change Type +| Root `uv.lock` / `pyproject.toml` | SDK (the gate requires a `prowler/changelog.d/` fragment) | +| Multiple | One fragment per affected component | -| Change | Section | -|--------|---------| -| New feature, check, endpoint | 🚀 Added | -| Behavior change, refactor | 🔄 Changed | -| Bug fix | 🐞 Fixed | -| CVE patch, security improvement | 🔐 Security | -| Feature removal | ❌ Removed | -| Deprecation notice | ⚠️ Deprecated | +### Step 2: Create the fragment(s) -### Step 3: Add Entry at BOTTOM of Appropriate Section - -**CRITICAL:** Add new entries at the BOTTOM of each section, NOT at the top. - -**CRITICAL:** The link MUST point to the PR (`/pull/N`). Linking to `/issues/N` is FORBIDDEN. If the PR closes an issue, that mapping goes in the PR body via `Fixes #N` — never in the changelog entry. - -```markdown -## [1.17.0] (Prowler UNRELEASED) - -### 🐞 Fixed +```bash +echo 'Entry text describing the change' > /changelog.d/..md +``` -- Existing fix one [(#9997)](https://github.com/prowler-cloud/prowler/pull/9997) -- Existing fix two [(#9998)](https://github.com/prowler-cloud/prowler/pull/9998) -- Button alignment in dashboard header [(#9999)](https://github.com/prowler-cloud/prowler/pull/9999) ← NEW ENTRY AT BOTTOM +### Step 3: Check pending fragments -### 🔐 Security +```bash +ls prowler/changelog.d/ api/changelog.d/ ui/changelog.d/ mcp_server/changelog.d/ ``` -This maintains chronological order within each section (oldest at top, newest at bottom). +## PR Changelog Gate -## Examples +The `pr-check-changelog.yml` workflow enforces fragments: -### Good Entries +1. **REQUIRED**: PRs touching `ui/`, `api/`, `mcp_server/`, or `prowler/` MUST add (or fix) a fragment under the corresponding `changelog.d/` +2. **VALIDATED**: added fragment filenames must match `..md` with a valid type +3. **LINTED**: fragment content must NOT contain a hand-written PR link (`[(#N)](...)`); the gate fails if one is found because the link is attached automatically at compile time +4. **SKIP**: Add `no-changelog` label to bypass (use sparingly for docs-only, CI-only changes) -```markdown -### 🚀 Added -- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) +## Release flow (compile) -### 🐞 Fixed -- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) +- At release time, the `compile-changelogs` workflow (manual dispatch: `prowler_version` + `target_branch`; per-component versions are auto-derived from each changelog's latest stamped heading plus the pending fragment types, with optional explicit overrides or `skip`) resolves each fragment's PR from git history, runs the compiler per component, and opens a `chore(changelog): vX.Y.Z` PR (labeled `no-changelog`) that inserts the stamped `## [X.Y.Z] (Prowler vX.Y.Z)` block into each `CHANGELOG.md` and deletes the consumed fragments. A human reviews and squash-merges it. `prepare-release.yml` then extracts the stamped sections exactly as before. +- **Minor release (X.Y.0):** compile on `master` and merge the compile PR BEFORE cutting the `v5.X` branch. +- **Patch release (X.Y.Z):** fixes are backported to `v5.X` with their fragment files (conflict-free); compile on `v5.X` and merge its PR there. The same workflow run automatically opens a second forward-sync PR against master (labeled `no-changelog`) that inserts the same stamped block under master's marker and deletes the consumed fragments, so the next minor cannot re-release them; merge it right after. Fragments that only existed on `v5.X` are skipped with a notice. No manual git is involved. +- Entries within a section are ordered by PR number ascending (approximately chronological). Do not fight this ordering. -### 🔐 Security -- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) -``` +## Fixing an already-released entry -### Readable Technical Entries +Released version blocks in `CHANGELOG.md` are otherwise immutable, but typo/correction fixes to already-released entries are the one case where a PR edits `CHANGELOG.md` directly: make the edit and add the `no-changelog` label. -```markdown -# GOOD - Technical but readable -### 🐞 Fixed -- `POST /api/v1/scans` no longer intermittently fails with `Scan matching query does not exist`; scan dispatch now publishes the `scan-perform` Celery task after the transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122) -- `entra_users_mfa_capable` no longer flags disabled guest users; Microsoft Graph is now the source of truth for `account_enabled` because EXO `Get-User` omits guest users [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002) -``` +If a PR's entry shipped in the wrong released block (e.g. the PR merged after its release was cut), move the entry back to a fragment: delete it from the released block and recreate it as `/changelog.d/..md` (label the PR `no-changelog` since it edits `CHANGELOG.md`). + +## Compiled CHANGELOG.md format (for reference) -### Bad Entries +The compiler renders, per release, into each `CHANGELOG.md` right under the `` marker (never remove that marker): ```markdown -# BAD - Wrong section order (Fixed before Added) -### 🐞 Fixed -- Some bug fix [(#123)](...) +## [X.Y.Z] (Prowler vA.B.C) ### 🚀 Added -- Some new feature [(#456)](...) - -- Fixed bug. # Too vague, has period -- Added new feature for users # Missing PR link, redundant verb -- Add search bar [(#123)] # Redundant verb (section already says "Added") -- This PR adds a cool new thing (#123) # Wrong link format, conversational -- Some bug fix [(#123)](https://github.com/prowler-cloud/prowler/issues/123) # FORBIDDEN: must link to /pull/N, never /issues/N -- POST /api/v1/scanswas intermittently failing withScan matching query does not existin thescan-performworker (#11122) # Missing spaces/backticks, unreadable -- entra_users_mfa_capable no longer flags disabled guest users by requesting accountEnabled and userType from Microsoft Graph via $select and using Graph as the source of truth for account_enabled (EXO Get-User does not return guest users) (#11002) # Run-on sentence, identifiers not formatted -``` -## PR Changelog Gate - -The `pr-check-changelog.yml` workflow enforces changelog entries: +- Entry text [(#NNNN)](https://github.com/prowler-cloud/prowler/pull/NNNN) -1. **REQUIRED**: PRs touching `ui/`, `api/`, `mcp_server/`, or `prowler/` MUST update the corresponding changelog -2. **SKIP**: Add `no-changelog` label to bypass (use sparingly for docs-only, CI-only changes) +### 🐞 Fixed -## Commands +- Fix entry [(#NNNN)](https://github.com/prowler-cloud/prowler/pull/NNNN) -```bash -# Check which changelogs need updates based on changed files -git diff main...HEAD --name-only | grep -E '^(ui|api|mcp_server|prowler)/' | cut -d/ -f1 | sort -u - -# View current UNRELEASED section -head -50 ui/CHANGELOG.md -head -50 api/CHANGELOG.md -head -50 mcp_server/CHANGELOG.md -head -50 prowler/CHANGELOG.md +--- ``` -## Migration Note - -**API, MCP Server, and SDK changelogs currently lack emojis.** When editing these files, add emoji prefixes to section headers as you update them: - -```markdown -# Before (legacy) -### Added - -# After (standardized) -### 🚀 Added -``` +Section order is always: Added → Changed → Deprecated → Removed → Fixed → Security. `X.Y.Z` is the COMPONENT version; `A.B.C` is the Prowler release version. Every entry ends with its PR link; linking to `/issues/N` is forbidden (the issue↔PR mapping belongs in the PR body via `Fixes #N`). ## Resources diff --git a/skills/prowler-changelog/assets/entry-templates.md b/skills/prowler-changelog/assets/entry-templates.md index cc74efbb632..b2367b0219a 100644 --- a/skills/prowler-changelog/assets/entry-templates.md +++ b/skills/prowler-changelog/assets/entry-templates.md @@ -1,101 +1,73 @@ -# Changelog Entry Templates +# Changelog Fragment Templates -## Entry Placement Rule +## Fragment basics -**CRITICAL:** Always add new entries at the **BOTTOM** of each section (before the next section header or `---`). +One fragment file per entry, under the component's `changelog.d/`: -This maintains chronological order: oldest entries at top, newest at bottom. - -## Section Headers - -```markdown -### 🚀 Added -### 🔄 Changed -### ⚠️ Deprecated -### ❌ Removed -### 🐞 Fixed -### 🔐 Security +```text +/changelog.d/..md ``` -## Entry Patterns +- ``: free-form, descriptive (`[A-Za-z0-9][A-Za-z0-9._-]*`) +- ``: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security` +- Content: a single line with the entry text. **No PR link** (attached automatically at compile time) and no trailing period. -> **Note:** Section headers already provide the verb. Entries describe WHAT, not the action. -> -> **Link target rule:** Every entry MUST link to the PR (`https://github.com/prowler-cloud/prowler/pull/N`). Linking to `/issues/N` is FORBIDDEN — even when the PR fixes an issue. The issue↔PR mapping belongs in the PR body (`Fixes #N`), not here. +> **Note:** The section header already provides the verb. Entries describe WHAT, not the action. -### Feature Addition (🚀 Added) -```markdown -- Search bar when adding a provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- `{check_id}` check for {provider} provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- `/api/v1/{endpoint}` endpoint to {description} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -``` +## Content Patterns by Type -### Behavior Change (🔄 Changed) -```markdown -- Lighthouse AI MCP tool filtering from blacklist to whitelist approach [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- {package} from {old} to {new} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -``` +### Feature Addition (`.added.md`) -### Bug Fix (🐞 Fixed) -```markdown -- OCI update credentials form failing silently due to missing provider UID [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- {What was broken} in {component} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +```text +Search bar when adding a provider +`{check_id}` check for {provider} provider +`/api/v1/{endpoint}` endpoint to {description} ``` -> When a PR fixes a reported issue, the link still goes to the PR (`/pull/N`), never the issue (`/issues/N`). Reference the issue from the PR body with `Fixes #N`. - -### Security Patch (🔐 Security) -```markdown -- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -- {package} to version {version} (CVE-XXXX-XXXXX) [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) -``` +### Behavior Change (`.changed.md`) -### Removal (❌ Removed) -```markdown -- Deprecated {feature} from {location} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +```text +Lighthouse AI MCP tool filtering from blacklist to whitelist approach +{package} from {old} to {new} ``` -## Version Header Templates +### Bug Fix (`.fixed.md`) -### Unreleased -```markdown -## [X.Y.Z] (Prowler UNRELEASED) +```text +OCI update credentials form failing silently due to missing provider UID +{What was broken} in {component} ``` -### Released -```markdown -## [X.Y.Z] (Prowler vA.B.C) +### Security Patch (`.security.md`) ---- +```text +Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs +{package} to version {version} (CVE-XXXX-XXXXX) ``` -## Full Entry Example +### Removal (`.removed.md`) -```markdown -## [1.17.0] (Prowler UNRELEASED) - -### 🚀 Added - -- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) -- New findings table UI with new design system components [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) -- YOUR NEW ENTRY GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) - -### 🔄 Changed +```text +Deprecated {feature} from {location} +``` -- Lighthouse AI MCP tool filtering from blacklist to whitelist approach [(#9802)](https://github.com/prowler-cloud/prowler/pull/9802) -- YOUR NEW CHANGE GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +## Full Examples -### 🐞 Fixed +```bash +echo 'Search bar when adding a provider' > ui/changelog.d/provider-search-bar.added.md -- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) -- YOUR NEW FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +echo '`kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less' > prowler/changelog.d/kms-rotation-90d.added.md -### 🔐 Security +echo 'OCI update credentials form failing silently due to missing provider UID' > ui/changelog.d/oci-credentials-form.fixed.md +``` -- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) -- YOUR NEW SECURITY FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +Several entries in one PR → one file per entry, freely mixing types (different slugs when the type repeats): ---- +```text +prowler/changelog.d/kms-rotation-check.added.md +prowler/changelog.d/kms-rotation-docs.added.md +prowler/changelog.d/kms-metadata-cache.changed.md +prowler/changelog.d/kms-disabled-keys.fixed.md ``` -> **Remember:** Each new entry is added at the BOTTOM of its section to maintain chronological order. +> **Remember:** never include the PR link in the fragment text; the compile step resolves and appends it automatically. diff --git a/skills/prowler-ci/SKILL.md b/skills/prowler-ci/SKILL.md index b673178c8ca..e956cd4ee0f 100644 --- a/skills/prowler-ci/SKILL.md +++ b/skills/prowler-ci/SKILL.md @@ -31,7 +31,8 @@ Use this skill whenever you are: - PR template: `.github/pull_request_template.md` - PR title validation: `.github/workflows/conventional-commit.yml` -- Changelog gate: `.github/workflows/pr-check-changelog.yml` +- Changelog gate: `.github/workflows/pr-check-changelog.yml` (requires a fragment under `/changelog.d/`) +- Changelog compile (release time): `.github/workflows/compile-changelogs.yml` - Conflict markers check: `.github/workflows/pr-conflict-checker.yml` - Secret scanning: `.github/workflows/find-secrets.yml` - Auto labels: `.github/workflows/labeler.yml` and `.github/labeler.yml` @@ -42,7 +43,7 @@ Use this skill whenever you are: 1. Identify which workflow/job is failing (name + file under `.github/workflows/`). 2. Check path filters: is the workflow supposed to run for your changed files? 3. If it's a title check: verify PR title matches Conventional Commits. -4. If it's changelog: verify the right `CHANGELOG.md` is updated OR apply `no-changelog` label. +4. If it's changelog: verify a valid fragment exists under the right `/changelog.d/` OR apply `no-changelog` label. 5. If it's conflict checker: remove `<<<<<<<`, `=======`, `>>>>>>>` markers. 6. If it's secrets (TruffleHog): see section below. diff --git a/skills/prowler-compliance-review/SKILL.md b/skills/prowler-compliance-review/SKILL.md index 06f371f81b9..6136467538a 100644 --- a/skills/prowler-compliance-review/SKILL.md +++ b/skills/prowler-compliance-review/SKILL.md @@ -27,7 +27,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task | JSON Valid | `python3 -m json.tool file.json` | No syntax errors | | All Checks Exist | Run validation script | 0 missing checks | | No Duplicate IDs | Run validation script | 0 duplicate requirement IDs | -| CHANGELOG Entry | Manual review | Present under correct version | +| Changelog fragment | Manual review | Fragment present under `prowler/changelog.d/` | | Dashboard File | Compare with existing | Follows established pattern | | Framework Metadata | Manual review | All required fields populated | @@ -63,8 +63,8 @@ JSON Valid? Duplicate Requirement IDs? ├── Yes → FAIL: Fix duplicate IDs └── No ↓ - CHANGELOG Entry Present? - ├── No → REQUEST CHANGES: Add CHANGELOG entry + Changelog Fragment Present? + ├── No → REQUEST CHANGES: Add changelog fragment └── Yes ↓ Dashboard File Follows Pattern? ├── No → REQUEST CHANGES: Fix dashboard pattern @@ -124,7 +124,7 @@ Compliance frameworks are JSON files in: `prowler/compliance/{provider}/{framewo | Empty Checks for Automated | AssessmentStatus is Automated but Checks is empty | Add checks or change to Manual | | Wrong file location | Framework not in `prowler/compliance/{provider}/` | Move to correct directory | | Missing dashboard file | No corresponding `dashboard/compliance/{framework}.py` | Create dashboard file following pattern | -| CHANGELOG missing | Not under correct version section | Add entry to prowler/CHANGELOG.md | +| Changelog fragment missing | No fragment file in the PR diff | Add a fragment under prowler/changelog.d/ | --- diff --git a/skills/prowler-compliance-review/references/review-checklist.md b/skills/prowler-compliance-review/references/review-checklist.md index d8673d8c03a..34e5e26c676 100644 --- a/skills/prowler-compliance-review/references/review-checklist.md +++ b/skills/prowler-compliance-review/references/review-checklist.md @@ -16,7 +16,7 @@ |-----------|----------| | Compliance JSON | `prowler/compliance/{provider}/{framework}.json` | | Dashboard | `dashboard/compliance/{framework}_{provider}.py` | -| CHANGELOG | `prowler/CHANGELOG.md` | +| Changelog fragment | `prowler/changelog.d/` | | Checks | `prowler/providers/{provider}/services/{service}/{check}/` | ## Validation Script @@ -40,7 +40,7 @@ When completing a compliance framework review, use this summary format: | JSON Valid | PASS/FAIL | | All Checks Exist | PASS/FAIL (N missing) | | No Duplicate IDs | PASS/FAIL | -| CHANGELOG Entry | PASS/FAIL | +| Changelog fragment | PASS/FAIL | | Dashboard File | PASS/FAIL | ### Statistics diff --git a/skills/prowler-pr/SKILL.md b/skills/prowler-pr/SKILL.md index e147d37fbe3..ee18f7205f6 100644 --- a/skills/prowler-pr/SKILL.md +++ b/skills/prowler-pr/SKILL.md @@ -56,7 +56,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task - [ ] Review if code is being documented following https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings - [ ] Review if backport is needed. - [ ] Review if is needed to change the Readme.md -- [ ] Ensure new entries are added to CHANGELOG.md, if applicable. +- [ ] Ensure a changelog fragment is added under /changelog.d/, if applicable. #### SDK/CLI - Are there new checks included in this PR? Yes / No @@ -67,7 +67,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task - [ ] Screenshots/Video - Mobile (X < 640px) - [ ] Screenshots/Video - Tablet (640px > X < 1024px) - [ ] Screenshots/Video - Desktop (X > 1024px) -- [ ] Ensure new entries are added to ui/CHANGELOG.md +- [ ] Ensure a changelog fragment is added under ui/changelog.d/ #### API (if applicable) - [ ] All issue/task requirements work as expected on the API @@ -77,7 +77,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task - [ ] Any other relevant evidence of the implementation (if applicable) - [ ] Verify if API specs need to be regenerated. - [ ] Check if version updates are required. -- [ ] Ensure new entries are added to api/CHANGELOG.md +- [ ] Ensure a changelog fragment is added under api/changelog.d/ ### License @@ -86,12 +86,12 @@ By submitting this pull request, I confirm that my contribution is made under th ## Component-Specific Rules -| Component | CHANGELOG | Extra Checks | -|-----------|-----------|--------------| -| SDK | `prowler/CHANGELOG.md` | New checks → permissions update? | -| API | `api/CHANGELOG.md` | API specs, version bump, endpoint output, EXPLAIN ANALYZE, performance | -| UI | `ui/CHANGELOG.md` | Screenshots for Mobile/Tablet/Desktop | -| MCP | `mcp_server/CHANGELOG.md` | N/A | +| Component | Changelog fragment | Extra Checks | +|-----------|--------------------|--------------| +| SDK | `prowler/changelog.d/` | New checks → permissions update? | +| API | `api/changelog.d/` | API specs, version bump, endpoint output, EXPLAIN ANALYZE, performance | +| UI | `ui/changelog.d/` | Screenshots for Mobile/Tablet/Desktop | +| MCP | `mcp_server/changelog.d/` | N/A | ## Commands @@ -128,7 +128,7 @@ Follow conventional commits: 1. ✅ All tests pass locally 2. ✅ Linting passes (`make lint` or component-specific) -3. ✅ CHANGELOG updated (if applicable) +3. ✅ Changelog fragment added (if applicable) 4. ✅ Branch is up to date with main 5. ✅ Commits are clean and descriptive diff --git a/tests/github/changelog_attribution_test.py b/tests/github/changelog_attribution_test.py new file mode 100644 index 00000000000..9cd8d016f0e --- /dev/null +++ b/tests/github/changelog_attribution_test.py @@ -0,0 +1,178 @@ +import importlib.util +import shutil +import sys +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[2] + / ".github" + / "scripts" + / "changelog_attribution.py" +) +SPEC = importlib.util.spec_from_file_location("changelog_attribution", MODULE_PATH) +changelog_attribution = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(changelog_attribution) + + +def make_component(tmp_path): + component = tmp_path / "prowler" + fragments_dir = component / "changelog.d" + fragments_dir.mkdir(parents=True) + return component, fragments_dir + + +def fake_git_mv(*args): + if args[0] != "mv": + raise AssertionError(f"Unexpected git command: {args}") + shutil.move(args[1], args[2]) + return "" + + +def fail_git(*args): + raise AssertionError(f"Unexpected git command: {args}") + + +def fail_api(*args): + raise AssertionError(f"Unexpected GitHub API call: {args}") + + +def run_main(monkeypatch, component, *extra_args): + monkeypatch.setattr( + sys, + "argv", + ["changelog_attribution.py", str(component), *extra_args], + ) + return changelog_attribution.main() + + +class TestChangelogAttribution: + def test_renames_slug_fragment_to_resolved_pr_number(self, tmp_path, monkeypatch): + component, fragments_dir = make_component(tmp_path) + fragment = fragments_dir / "add-workflow.fixed.md" + fragment.write_text("Fix changelog workflow.\n") + + monkeypatch.setattr( + changelog_attribution, "find_adding_commit", lambda path: "abc123" + ) + monkeypatch.setattr( + changelog_attribution, "pr_from_api", lambda repo, sha: 11572 + ) + monkeypatch.setattr(changelog_attribution, "git", fake_git_mv) + + assert run_main(monkeypatch, component) == 0 + assert not fragment.exists() + assert (fragments_dir / "11572.fixed.md").read_text() == ( + "Fix changelog workflow.\n" + ) + + def test_appends_counter_when_pr_fragment_already_exists( + self, tmp_path, monkeypatch + ): + component, fragments_dir = make_component(tmp_path) + (fragments_dir / "11572.fixed.md").write_text("Existing fix.\n") + fragment = fragments_dir / "another-fix.fixed.md" + fragment.write_text("Another fix.\n") + + monkeypatch.setattr( + changelog_attribution, "find_adding_commit", lambda path: "abc123" + ) + monkeypatch.setattr( + changelog_attribution, "pr_from_api", lambda repo, sha: 11572 + ) + monkeypatch.setattr(changelog_attribution, "git", fake_git_mv) + + assert run_main(monkeypatch, component) == 0 + assert (fragments_dir / "11572.fixed.md").read_text() == "Existing fix.\n" + assert (fragments_dir / "11572.fixed.1.md").read_text() == "Another fix.\n" + + def test_uses_subject_fallback_when_api_is_disabled(self, tmp_path, monkeypatch): + component, fragments_dir = make_component(tmp_path) + fragment = fragments_dir / "fallback.added.md" + fragment.write_text("Add fallback behavior.\n") + + monkeypatch.setattr( + changelog_attribution, "find_adding_commit", lambda path: "abc123" + ) + monkeypatch.setattr( + changelog_attribution, + "pr_from_api", + fail_api, + ) + monkeypatch.setattr(changelog_attribution, "pr_from_subject", lambda sha: 42) + monkeypatch.setattr(changelog_attribution, "git", fake_git_mv) + + assert run_main(monkeypatch, component, "--no-api") == 0 + assert not fragment.exists() + assert (fragments_dir / "42.added.md").read_text() == "Add fallback behavior.\n" + + def test_renames_unresolved_fragment_to_orphan(self, tmp_path, monkeypatch, capsys): + component, fragments_dir = make_component(tmp_path) + fragment = fragments_dir / "manual.changed.md" + fragment.write_text("Change manual entry.\n") + + monkeypatch.setattr( + changelog_attribution, "find_adding_commit", lambda path: None + ) + monkeypatch.setattr(changelog_attribution, "git", fake_git_mv) + + assert run_main(monkeypatch, component) == 0 + assert not fragment.exists() + assert (fragments_dir / "+manual.changed.md").read_text() == ( + "Change manual entry.\n" + ) + assert "Could not resolve a PR" in capsys.readouterr().out + + def test_rejects_malformed_fragment_names(self, tmp_path, monkeypatch, capsys): + component, fragments_dir = make_component(tmp_path) + fragment = fragments_dir / "bad.bugfix.md" + fragment.write_text("Invalid type.\n") + + monkeypatch.setattr( + changelog_attribution, + "git", + fail_git, + ) + + assert run_main(monkeypatch, component) == 1 + assert fragment.exists() + assert "Malformed fragment filename" in capsys.readouterr().out + + def test_rejects_malformed_names_before_renaming_any_fragment( + self, tmp_path, monkeypatch, capsys + ): + component, fragments_dir = make_component(tmp_path) + valid_fragment = fragments_dir / "valid.fixed.md" + malformed_fragment = fragments_dir / "bad.bugfix.md" + valid_fragment.write_text("Valid fix.\n") + malformed_fragment.write_text("Invalid type.\n") + + monkeypatch.setattr( + changelog_attribution, "find_adding_commit", lambda path: "abc123" + ) + monkeypatch.setattr( + changelog_attribution, "pr_from_api", lambda repo, sha: 11572 + ) + monkeypatch.setattr(changelog_attribution, "git", fail_git) + + assert run_main(monkeypatch, component) == 1 + assert valid_fragment.read_text() == "Valid fix.\n" + assert malformed_fragment.read_text() == "Invalid type.\n" + assert not (fragments_dir / "11572.fixed.md").exists() + assert "Malformed fragment filename" in capsys.readouterr().out + + def test_skips_fragments_that_already_start_with_pr_number( + self, tmp_path, monkeypatch + ): + component, fragments_dir = make_component(tmp_path) + fragment = fragments_dir / "11572.added.md" + fragment.write_text("Already attributed.\n") + + monkeypatch.setattr( + changelog_attribution, + "git", + fail_git, + ) + + assert run_main(monkeypatch, component) == 0 + assert fragment.read_text() == "Already attributed.\n" diff --git a/tests/github/changelog_fragments_test.py b/tests/github/changelog_fragments_test.py new file mode 100644 index 00000000000..13cf65b3896 --- /dev/null +++ b/tests/github/changelog_fragments_test.py @@ -0,0 +1,314 @@ +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +EXPECTED_CHANGELOG_TYPE_ORDER = [ + "added", + "changed", + "deprecated", + "removed", + "fixed", + "security", +] +EXPECTED_CHANGELOG_SECTION_NAMES = [ + "🚀 Added", + "🔄 Changed", + "⚠️ Deprecated", + "❌ Removed", + "🐞 Fixed", + "🔐 Security", +] +COMPONENTS = ["prowler", "api", "ui", "mcp_server"] + + +def read_workflow(name): + return (REPO_ROOT / ".github" / "workflows" / name).read_text() + + +def render_towncrier(tmp_path, type_definitions, fragments): + pytest.importorskip("towncrier") + + fragments_dir = tmp_path / "changelog.d" + fragments_dir.mkdir() + (tmp_path / "CHANGELOG.md").write_text( + "# Changelog\n\n\n" + ) + for filename, content in fragments.items(): + (fragments_dir / filename).write_text(f"{content}\n") + + type_config = "\n".join( + "\n".join( + [ + "[[tool.towncrier.type]]", + f'directory = "{directory}"', + f'name = "{name}"', + "showcontent = true", + ] + ) + for directory, name in type_definitions + ) + config = "\n".join( + [ + "[tool.towncrier]", + 'directory = "changelog.d"', + 'filename = "CHANGELOG.md"', + 'start_string = "\\n"', + f'template = "{(REPO_ROOT / ".github/towncrier/template.md.jinja").as_posix()}"', + 'title_format = "## [{version}] ({name})"', + 'issue_format = "[(#{issue})](https://example.com/pull/{issue})"', + 'underlines = ["", "", ""]', + type_config, + ] + ) + config_path = tmp_path / "towncrier.toml" + config_path.write_text(config) + + result = subprocess.run( + [ + sys.executable, + "-m", + "towncrier", + "build", + "--config", + str(config_path), + "--version", + "0.1.0", + "--name", + "Test", + "--draft", + ], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.split("## [0.1.0] (Test)", 1)[1] + + +def test_towncrier_template_uses_configured_changelog_section_order(tmp_path): + template = (REPO_ROOT / ".github/towncrier/template.md.jinja").read_text() + + assert "{% set category_order = definitions.keys() %}" in template + assert ( + "{% for category in category_order if category in sections[section] %}" + in template + ) + assert "definitions.items()" not in template + assert ( + '["added", "changed", "deprecated", "removed", "fixed", "security"]' + not in template + ) + + output = render_towncrier( + tmp_path, + [("fixed", "Fixed"), ("added", "Added"), ("custom", "Custom")], + { + "1.added.md": "Entry one", + "2.added.md": "Entry two", + "3.fixed.md": "Fix entry", + "4.custom.md": "Custom entry", + }, + ) + + headings = re.findall(r"^### (.+)$", output, re.MULTILINE) + assert headings == ["Fixed", "Added", "Custom"] + assert ( + "- Entry one [(#1)](https://example.com/pull/1)\n" + "- Entry two [(#2)](https://example.com/pull/2)" + ) in output + assert ( + "- Entry one [(#1)](https://example.com/pull/1)\n\n" + "- Entry two [(#2)](https://example.com/pull/2)" + ) not in output + + +def test_component_towncrier_configs_keep_changelog_section_order(): + for component in COMPONENTS: + config = tomllib.loads((REPO_ROOT / component / "towncrier.toml").read_text()) + type_definitions = config["tool"]["towncrier"]["type"] + + assert [item["directory"] for item in type_definitions] == ( + EXPECTED_CHANGELOG_TYPE_ORDER + ) + assert [item["name"] for item in type_definitions] == ( + EXPECTED_CHANGELOG_SECTION_NAMES + ) + + +def test_pull_request_template_links_to_all_fragment_locations(): + template = (REPO_ROOT / ".github/pull_request_template.md").read_text() + + assert "[Prowler Community Slack](https://goto.prowler.com/slack)" in template + assert "[Prowler Community Slack](goto.prowler.com/slack)" not in template + for component in COMPONENTS: + assert f"{component}/changelog.d/" in template + + +def test_changelog_gate_rejects_direct_changelog_edits(): + workflow = read_workflow("pr-check-changelog.yml") + has_update = re.search( + r"(?ms)^\s*has_changelog_update\(\) \{\n(?P.*?)^\s*\}\n", + workflow, + ) + + assert has_update is not None + assert "CHANGELOG.md" not in has_update.group("body") + assert "handwritten_changelogs" in workflow + assert "Direct CHANGELOG.md edits are not allowed" in workflow + assert "direct CHANGELOG.md edit" not in workflow + + +def test_changelog_gate_tests_compile_workflow_changes(): + workflow = read_workflow("pr-check-changelog.yml") + + assert ".github/workflows/compile-changelogs.yml" in workflow + + +def test_changelog_attribution_tests_use_pinned_python(): + workflow = read_workflow("pr-check-changelog.yml") + match = re.search( + r"(?ms)^ test-changelog-attribution:\n(?P.*?)^ check-changelog:", + workflow, + ) + + assert match is not None + job = match.group("body") + assert "uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c" in job + assert "python-version: '3.12'" in job + assert "python3 -m pip install" in job + assert "python3 -m pytest tests/github" in job + assert "objects.githubusercontent.com:443" in job + + +def test_changelog_gate_rejects_common_manual_pr_link_forms(): + workflow = read_workflow("pr-check-changelog.yml") + + assert "manual_pr_link_re=" in workflow + assert r"\[\(#[0-9]+\)\]" in workflow + assert r"\[#[0-9]+\]\(" in workflow + assert r"\(#[0-9]+\)" in workflow + assert r"github\.com/[^[:space:]/]+/[^[:space:]/]+/(pull|issues)/[0-9]+" in workflow + + +def test_changelog_gate_derives_fragment_paths_from_monitored_folders(): + workflow = read_workflow("pr-check-changelog.yml") + + assert "folder_alt=$(echo \"$MONITORED_FOLDERS\" | tr ' ' '|')" in workflow + assert "^(api|ui|prowler|mcp_server)/changelog\\.d/" not in workflow + + +def test_changelog_gate_lints_renamed_fragments(): + workflow = read_workflow("pr-check-changelog.yml") + + assert "STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES" in workflow + assert "${{ steps.changed-files.outputs.renamed_files }}" in workflow + assert "added_or_renamed=" in workflow + assert "added_modified_or_renamed=" in workflow + assert ( + 'echo "$added_or_renamed" | grep -E "^(${folder_alt})/changelog\\.d/"' + in workflow + ) + assert ( + 'echo "$added_modified_or_renamed" | grep -E "^(${folder_alt})/changelog\\.d/"' + in workflow + ) + + +def test_changelog_gate_uses_random_github_output_delimiters(): + workflow = read_workflow("pr-check-changelog.yml") + + assert "write_multiline_output()" in workflow + assert "openssl rand -hex 16" in workflow + assert '>> "$GITHUB_OUTPUT"' in workflow + assert "< "$existing_key" ]]' in workflow + assert "already contains a block for Prowler v${incoming_release}" in workflow + assert 'head -n "$marker_line" "$component/CHANGELOG.md"' not in workflow + + +def test_compile_workflow_rejects_explicit_versions_that_do_not_bump(): + workflow = read_workflow("compile-changelogs.yml") + + assert 'current=$(latest_released_version "$component")' in workflow + assert 'current_key=$(version_key "$current")' in workflow + assert 'effective_key=$(version_key "$effective")' in workflow + assert ( + '[[ "$effective_key" < "$current_key" || "$effective_key" == "$current_key" ]]' + in workflow + ) + assert ( + "explicit version '${effective}' must be greater than the latest released version" + in workflow + ) + + +def test_compile_workflow_requires_target_branch_to_match_prowler_version(): + workflow = read_workflow("compile-changelogs.yml") + + assert ( + 'IFS=. read -r prowler_major prowler_minor prowler_patch <<< "$PROWLER_VERSION"' + in workflow + ) + assert "prowler_patch=$((10#$prowler_patch))" in workflow + assert 'if [ "$prowler_patch" -eq 0 ]; then' in workflow + assert "target_branch must be 'master' for Prowler ${PROWLER_VERSION}" in workflow + assert 'expected_target_branch="v${prowler_major}.${prowler_minor}"' in workflow + assert 'if [ "$TARGET_BRANCH" != "$expected_target_branch" ]; then' in workflow + assert ( + "target_branch must be '${expected_target_branch}' for Prowler ${PROWLER_VERSION}" + in workflow + ) + + +def test_compile_workflow_normalizes_version_segments_before_arithmetic(): + workflow = read_workflow("compile-changelogs.yml") + + assert ( + 'printf \'%06d.%06d.%06d\' "$((10#$major))" "$((10#$minor))" "$((10#$patch))"' + in workflow + ) + assert "major=$((10#$major))" in workflow + assert "minor=$((10#$minor))" in workflow + assert "patch=$((10#$patch))" in workflow + assert "current_major=$((10#$current_major))" in workflow + assert "effective_major=$((10#$effective_major))" in workflow + assert "effective_minor=$((10#$effective_minor))" in workflow + assert "effective_patch=$((10#$effective_patch))" in workflow + + +def test_compile_workflow_requires_removed_fragments_in_major_releases(): + workflow = read_workflow("compile-changelogs.yml") + + assert "has_removed_fragments" in workflow + assert "removed fragments require a major component release" in workflow + assert "effective_major" in workflow + assert "effective_minor" in workflow + assert "effective_patch" in workflow diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index bab2b9cde21..0c122f8ee06 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to the **Prowler UI** are documented in this file. + + ## [1.33.0] (Prowler v5.33.0) ### 🚀 Added diff --git a/ui/changelog.d/.gitkeep b/ui/changelog.d/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ui/changelog.d/README.md b/ui/changelog.d/README.md new file mode 100644 index 00000000000..0853174f309 --- /dev/null +++ b/ui/changelog.d/README.md @@ -0,0 +1,10 @@ +# Changelog fragments + +Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict. + +- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`) +- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security` +- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time) +- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs + +Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`. diff --git a/ui/towncrier.toml b/ui/towncrier.toml new file mode 100644 index 00000000000..528e6129ece --- /dev/null +++ b/ui/towncrier.toml @@ -0,0 +1,39 @@ +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +title_format = "## [{version}] ({name})" +issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})" +template = "../.github/towncrier/template.md.jinja" +underlines = ["", "", ""] +ignore = [".gitkeep", "README.md"] + +[[tool.towncrier.type]] +directory = "added" +name = "🚀 Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "🔄 Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "⚠️ Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "❌ Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "🐞 Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "security" +name = "🔐 Security" +showcontent = true