mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat: add changelog fragments workflow (#11572)
This commit is contained in:
@@ -62,6 +62,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -108,6 +108,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -77,6 +77,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -111,6 +111,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -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 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md"; then
|
||||
echo "::error::${component}/CHANGELOG.md is missing the '<!-- changelog: release notes start -->' 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 '^<!-- changelog: release notes start -->$' "$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: release notes start -->$' "$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 '^<!-- changelog: release notes start -->$' "$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
|
||||
@@ -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'
|
||||
|
||||
@@ -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<<EOF"
|
||||
echo -e "${missing_changelogs}"
|
||||
echo "EOF"
|
||||
} >> $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 \`<slug>.<type>.md\` under \`<component>/changelog.d/\`, where \`<type>\` 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' > <component>/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: |
|
||||
<!-- changelog-check -->
|
||||
${{ 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
|
||||
|
||||
@@ -55,6 +55,7 @@ jobs:
|
||||
files_ignore: |
|
||||
.github/**
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
docs/**
|
||||
permissions/**
|
||||
api/**
|
||||
|
||||
@@ -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 * * *'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,7 @@ jobs:
|
||||
files_ignore: |
|
||||
.github/**
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
docs/**
|
||||
permissions/**
|
||||
api/**
|
||||
|
||||
@@ -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 * * *'
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ jobs:
|
||||
files: ui/**
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
ui/README.md
|
||||
ui/AGENTS.md
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
.github/workflows/ui-tests.yml
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
ui/README.md
|
||||
ui/AGENTS.md
|
||||
|
||||
|
||||
Reference in New Issue
Block a user