diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f2065a3db0..aeb87d3a59 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 0000000000..d099e5d243 --- /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 0000000000..5d89185c03 --- /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 362f0dbbbf..93e8d1006d 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 d12bf5d2d4..b6881f93f2 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 ed4e5476d2..d687ff0d30 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 bbb20cdadc..5473139414 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 0000000000..b7e1b4fb93 --- /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 23e6ba2984..1f47c31189 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 0b9bc20552..82f090d4be 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 - { - echo "missing_changelogs<> $GITHUB_OUTPUT + # Suggest a slug derived from the branch name for the bot comment + suggested_slug=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's|.*/||; s/[^a-z0-9._-]/-/g; s/^[^a-z0-9]*//') + if [ -z "$suggested_slug" ]; then + suggested_slug="my-change" + fi + + fragment_help="A changelog fragment is a small Markdown file named \`..md\` under \`/changelog.d/\`, where \`\` is one of \`added\`, \`changed\`, \`deprecated\`, \`removed\`, \`fixed\` or \`security\`. Its content is the changelog entry text, without the PR link (added automatically at release time) and without a trailing period. For example: + + \`\`\` + echo 'Entry text describing the change' > /changelog.d/${suggested_slug}.fixed.md + \`\`\` + + If this PR does not need a changelog entry, add the \`no-changelog\` label instead." + + if [ -n "$missing_fragments" ] || [ -n "$invalid_fragments" ] || [ -n "$linked_fragments" ] || [ -n "$handwritten_changelogs" ]; then + comment_body="" + if [ -n "$missing_fragments" ]; then + comment_body="⚠️ **Changes detected in the following folders without a changelog fragment:**"$'\n\n'"${missing_fragments}"$'\n' + fi + if [ -n "$invalid_fragments" ]; then + comment_body="${comment_body}⚠️ **Changelog fragment filenames that do not follow the naming convention:**"$'\n\n'"${invalid_fragments}"$'\n' + fi + if [ -n "$linked_fragments" ]; then + comment_body="${comment_body}⚠️ **Changelog fragments containing a hand-written PR or issue link (remove it; the PR link is attached automatically at release time):**"$'\n\n'"${linked_fragments}"$'\n' + fi + if [ -n "$handwritten_changelogs" ]; then + comment_body="${comment_body}⚠️ **Direct \`CHANGELOG.md\` edits are not allowed in regular PRs:**"$'\n\n'"${handwritten_changelogs}"$'\n' + fi + comment_body="${comment_body}${fragment_help}" + else + comment_body="✅ All required changelog fragments are present." + fi + + write_multiline_output() { + local name="$1" + local value="$2" + local delimiter + + while true; do + delimiter="EOF_$(openssl rand -hex 16)" + if ! grep -qxF "$delimiter" <<< "$value"; then + break + fi + done + + { + echo "${name}<<${delimiter}" + if [ -n "$value" ]; then + printf '%s\n' "$value" + fi + echo "${delimiter}" + } >> "$GITHUB_OUTPUT" + } + + write_multiline_output "missing_fragments" "$missing_fragments" + write_multiline_output "invalid_fragments" "$invalid_fragments" + write_multiline_output "linked_fragments" "$linked_fragments" + write_multiline_output "handwritten_changelogs" "$handwritten_changelogs" + write_multiline_output "comment_body" "$comment_body" env: STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }} STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }} + STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES: ${{ steps.changed-files.outputs.modified_files }} + STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES: ${{ steps.changed-files.outputs.renamed_files }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} - name: Find existing changelog comment if: github.event.pull_request.head.repo.full_name == github.repository @@ -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`:** + ${{ steps.check-folders.outputs.comment_body }} - {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.' }} - - - 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 2b1efc69a9..289508ce7d 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 0696f7c6a5..a86d6dcc53 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 2b436db468..b4821a2fe7 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 11a7894ce8..192ef6279a 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 4fc6cab0b7..2fe6b1d536 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 3dcb4f42eb..41b1810591 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 a931e50f22..96b3315bc0 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 f406b24e81..5e7e7517bb 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 8f6b837ea3..5c917d3198 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 0000000000..e69de29bb2 diff --git a/api/changelog.d/README.md b/api/changelog.d/README.md new file mode 100644 index 0000000000..0853174f30 --- /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 0000000000..528e6129ec --- /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 d3e7d3631f..138cb65877 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 75392ed3a7..d80588dd24 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 8f1438a3bb..ec0a2e354f 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 0000000000..e69de29bb2 diff --git a/mcp_server/changelog.d/README.md b/mcp_server/changelog.d/README.md new file mode 100644 index 0000000000..0853174f30 --- /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 0000000000..528e6129ec --- /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 f73c1955a3..7a98933c5f 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 0000000000..e69de29bb2 diff --git a/prowler/changelog.d/11572.added.md b/prowler/changelog.d/11572.added.md new file mode 100644 index 0000000000..4838752200 --- /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 0000000000..0853174f30 --- /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 0000000000..528e6129ec --- /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 67e853b0fd..369da2ba55 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,70 +16,80 @@ 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) +## Fragment filename -### Added -### Changed -### Deprecated -### Removed -### Fixed -### Security +```text +..md ``` -### Emoji Prefixes (REQUIRED for ALL components) +- `` 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: -| 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 | +| `` | 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 | -### Entry Format +- 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. -```markdown -### Added +## Fragment content -- 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) +The file contains ONLY the entry text, exactly as it should appear in the changelog, on a single line ending with a trailing newline: -### Changed - -- 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 +### Good fragments + +```text +# ui/changelog.d/provider-search-bar.added.md +Search bar when adding a provider + +# 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 + +# ui/changelog.d/node-24-bump.security.md +Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs +``` + +### Bad fragments + +```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 +``` + +## Semantic Versioning Rules Prowler follows [semver.org](https://semver.org/): @@ -89,69 +99,26 @@ Prowler follows [semver.org](https://semver.org/): | 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 | -**CRITICAL:** `### ❌ Removed` entries MUST only appear in MAJOR version releases. Removing features is a breaking change. - -### Released Versions Are Immutable - -**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 -``` - -**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 - ---- # Horizontal rule between versions -``` - -## Mandatory Changelog Preflight - -Before editing any `CHANGELOG.md`, always inspect the active release boundary: - -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. - -**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,114 +127,63 @@ git diff main...HEAD --name-only | `api/**` | API | | `mcp_server/**` | MCP Server | | `prowler/**` | SDK | -| Multiple | Update ALL affected changelogs | +| Root `uv.lock` / `pyproject.toml` | SDK (the gate requires a `prowler/changelog.d/` fragment) | +| Multiple | One fragment per affected component | -### Step 2: Determine Change Type +### Step 2: Create the fragment(s) -| 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 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 - -- 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 - -### 🔐 Security +```bash +echo 'Entry text describing the change' > /changelog.d/..md ``` -This maintains chronological order within each section (oldest at top, newest at bottom). +### Step 3: Check pending fragments -## Examples - -### Good Entries - -```markdown -### 🚀 Added -- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) - -### 🐞 Fixed -- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) - -### 🔐 Security -- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) -``` - -### Readable Technical Entries - -```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) -``` - -### Bad Entries - -```markdown -# BAD - Wrong section order (Fixed before Added) -### 🐞 Fixed -- Some bug fix [(#123)](...) - -### 🚀 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 +```bash +ls prowler/changelog.d/ api/changelog.d/ ui/changelog.d/ mcp_server/changelog.d/ ``` ## PR Changelog Gate -The `pr-check-changelog.yml` workflow enforces changelog entries: +The `pr-check-changelog.yml` workflow enforces fragments: -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) +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) -## Commands +## Release flow (compile) -```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 +- 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. -# 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 -``` +## Fixing an already-released entry -## Migration Note +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. -**API, MCP Server, and SDK changelogs currently lack emojis.** When editing these files, add emoji prefixes to section headers as you update them: +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) + +The compiler renders, per release, into each `CHANGELOG.md` right under the `` marker (never remove that marker): ```markdown -# Before (legacy) -### Added +## [X.Y.Z] (Prowler vA.B.C) -# After (standardized) ### 🚀 Added + +- Entry text [(#NNNN)](https://github.com/prowler-cloud/prowler/pull/NNNN) + +### 🐞 Fixed + +- Fix entry [(#NNNN)](https://github.com/prowler-cloud/prowler/pull/NNNN) + +--- ``` +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 - **Templates**: See [assets/](assets/) for entry templates diff --git a/skills/prowler-changelog/assets/entry-templates.md b/skills/prowler-changelog/assets/entry-templates.md index cc74efbb63..b2367b0219 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 + +### Feature Addition (`.added.md`) + +```text +Search bar when adding a provider +`{check_id}` check for {provider} provider +`/api/v1/{endpoint}` endpoint to {description} ``` -### 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) +### Behavior Change (`.changed.md`) + +```text +Lighthouse AI MCP tool filtering from blacklist to whitelist approach +{package} from {old} to {new} ``` -### 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) +### Bug Fix (`.fixed.md`) + +```text +OCI update credentials form failing silently due to missing provider UID +{What was broken} in {component} ``` -> 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.md`) -### 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) +```text +Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs +{package} to version {version} (CVE-XXXX-XXXXX) ``` -### Removal (❌ Removed) -```markdown -- Deprecated {feature} from {location} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +### Removal (`.removed.md`) + +```text +Deprecated {feature} from {location} ``` -## Version Header Templates +## Full Examples -### Unreleased -```markdown -## [X.Y.Z] (Prowler UNRELEASED) +```bash +echo 'Search bar when adding a provider' > ui/changelog.d/provider-search-bar.added.md + +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 + +echo 'OCI update credentials form failing silently due to missing provider UID' > ui/changelog.d/oci-credentials-form.fixed.md ``` -### Released -```markdown -## [X.Y.Z] (Prowler vA.B.C) +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 ``` -## Full Entry Example - -```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 - -- 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) - -### 🐞 Fixed - -- 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) - -### 🔐 Security - -- 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) - ---- -``` - -> **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 b673178c8c..e956cd4ee0 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 06f371f81b..6136467538 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 d8673d8c03..34e5e26c67 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 e147d37fbe..ee18f7205f 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 0000000000..9cd8d016f0 --- /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 0000000000..13cf65b389 --- /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 115ef1e2b5..0c484d17a3 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 0000000000..e69de29bb2 diff --git a/ui/changelog.d/README.md b/ui/changelog.d/README.md new file mode 100644 index 0000000000..0853174f30 --- /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 0000000000..528e6129ec --- /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