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 = mirrors prowler_version; "skip" = hold this component back)' required: false type: string api_version: description: 'API version override (empty = auto-derive 1..; "skip" = hold back)' required: false type: string ui_version: description: 'UI version override (empty = auto-derive 1..; "skip" = hold back)' required: false type: string mcp_version: description: 'MCP Server version override (empty = auto-derive from pending fragment types; "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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 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 # SDK, UI, and API versions are deterministic mirrors of the # Prowler version (the scheme bump-version.yml codifies): the SDK # mirrors it directly, the UI tracks 1.., and the # API is the independent 1.. stream. Only the # MCP Server has its own cadence, derived from fragment types. IFS=. read -r _ prowler_minor prowler_patch <<< "$PROWLER_VERSION" prowler_minor=$((10#$prowler_minor)) prowler_patch=$((10#$prowler_patch)) case "$component" in prowler) effective="$PROWLER_VERSION" ;; ui) effective="1.${prowler_minor}.${prowler_patch}" ;; api) effective="1.$((prowler_minor + 1)).${prowler_patch}" ;; mcp_server) IFS=. read -r major minor patch <<< "$current" major=$((10#$major)) minor=$((10#$minor)) patch=$((10#$patch)) # Prowler patch releases (vN.N target) are maintenance # releases, so the MCP Server bumps patch regardless of # fragment types; a deliberate exception needs the explicit # version input. if [ "$TARGET_BRANCH" != "master" ]; then effective="${major}.${minor}.$((patch + 1))" if echo "$fragments" | grep -qE '\.(added|deprecated)(\.[0-9]+)?\.md$'; then echo "::warning::${component}: 'added'/'deprecated' fragments are shipping in a Prowler patch; auto-derived a patch bump (${current} -> ${effective}), pass the version input to override" fi elif echo "$fragments" | grep -qE '\.(added|changed|deprecated)(\.[0-9]+)?\.md$'; then effective="${major}.$((minor + 1)).0" else effective="${major}.${minor}.$((patch + 1))" fi ;; esac current_key=$(version_key "$current") effective_key=$(version_key "$effective") if [[ "$effective_key" < "$current_key" || "$effective_key" == "$current_key" ]]; then echo "::error::${component}: auto-derived version '${effective}' is not greater than the latest released version (${current}); check prowler_version or pass the version input explicitly" errors=1 continue fi mode="auto" echo "::notice::${component}: version auto-derived ${current} -> ${effective}" 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 skip-sync # 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 total_lines 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 # The captured block window can be off by one blank line on either # end (towncrier re-emits the blank after the marker), so strip the # outer blank lines and pad exactly one on each side: the block # must never glue to the marker above or the next heading below. awk ' /[^[:space:]]/ { for (i = 0; i < pending; i++) print ""; pending = 0; print; started = 1; next } started { pending++ } ' "$block_file" > "${RUNNER_TEMP}/block-normalized.md" total_lines=$(wc -l < "$changelog") { head -n "$((insertion_line - 1))" "$changelog" if [ "$insertion_line" -gt 1 ] && [ -n "$(sed -n "$((insertion_line - 1))p" "$changelog")" ]; then echo "" fi cat "${RUNNER_TEMP}/block-normalized.md" if [ "$insertion_line" -le "$total_lines" ]; then echo "" fi 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 skip-sync