351 lines
10 KiB
Bash
Executable File
351 lines
10 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
# Validate CHANGELOG.md changes against the release contract.
|
|
#
|
|
# --fast-path additionally requires the complete PR diff to be exactly one
|
|
# in-place CHANGELOG.md modification. --content-only validates CHANGELOG.md
|
|
# while allowing other files to change in the same PR.
|
|
|
|
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
|
|
|
usage() {
|
|
printf '%s\n' "usage: $0 (--fast-path|--content-only) BASE HEAD" >&2
|
|
}
|
|
|
|
if [ "$#" -ne 3 ]; then
|
|
usage
|
|
exit 2
|
|
fi
|
|
|
|
case "$1" in
|
|
--fast-path)
|
|
VALIDATION_MODE="fast-path"
|
|
;;
|
|
--content-only)
|
|
VALIDATION_MODE="content-only"
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
BASE_REF="$2"
|
|
HEAD_REF="$3"
|
|
|
|
cd "$ROOT"
|
|
|
|
BASE_COMMIT="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")" || {
|
|
printf 'error: CHANGELOG base ref is not an available commit: %s\n' "$BASE_REF" >&2
|
|
exit 2
|
|
}
|
|
HEAD_COMMIT="$(git rev-parse --verify --quiet "${HEAD_REF}^{commit}")" || {
|
|
printf 'error: CHANGELOG head ref is not an available commit: %s\n' "$HEAD_REF" >&2
|
|
exit 2
|
|
}
|
|
|
|
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-changelog-pr.XXXXXX")"
|
|
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
|
|
|
if ! git merge-base --all "$BASE_COMMIT" "$HEAD_COMMIT" >"$TMP_ROOT/merge-bases"; then
|
|
printf 'error: CHANGELOG base and head commits have no merge base\n' >&2
|
|
exit 2
|
|
fi
|
|
merge_base_count="$(wc -l <"$TMP_ROOT/merge-bases" | tr -d '[:space:]')"
|
|
if [ "$merge_base_count" -ne 1 ]; then
|
|
printf 'error: expected exactly one merge base, found %s\n' "$merge_base_count" >&2
|
|
exit 2
|
|
fi
|
|
IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases"
|
|
|
|
require_regular_changelog() {
|
|
_rrc_label="$1"
|
|
_rrc_commit="$2"
|
|
if ! git ls-tree "$_rrc_commit" -- CHANGELOG.md |
|
|
awk '
|
|
NR == 1 &&
|
|
$1 == "100644" &&
|
|
$2 == "blob" &&
|
|
$4 == "CHANGELOG.md" {
|
|
valid = 1
|
|
}
|
|
END {
|
|
exit !(NR == 1 && valid)
|
|
}
|
|
'; then
|
|
printf 'error: CHANGELOG.md must be a regular 100644 blob at %s\n' \
|
|
"$_rrc_label" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# The merge base is the effective base of the PR diff. Also validate the
|
|
# caller-provided base commit so a stale or malformed target ref fails closed.
|
|
require_regular_changelog "base" "$BASE_COMMIT"
|
|
if [ "$MERGE_BASE" != "$BASE_COMMIT" ]; then
|
|
require_regular_changelog "merge base" "$MERGE_BASE"
|
|
fi
|
|
require_regular_changelog "head" "$HEAD_COMMIT"
|
|
|
|
git diff --no-ext-diff --find-renames --name-status \
|
|
"$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status"
|
|
case "$VALIDATION_MODE" in
|
|
fast-path)
|
|
printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status"
|
|
if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then
|
|
printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2
|
|
if [ -s "$TMP_ROOT/name-status" ]; then
|
|
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
|
else
|
|
printf '%s\n' ' (no changed files)' >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
;;
|
|
content-only)
|
|
if ! awk -F ' ' '
|
|
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 {
|
|
count++
|
|
}
|
|
END {
|
|
exit count != 1
|
|
}
|
|
' "$TMP_ROOT/name-status"; then
|
|
printf '%s\n' 'error: content-only validation requires an in-place modification: CHANGELOG.md' >&2
|
|
if [ -s "$TMP_ROOT/name-status" ]; then
|
|
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
|
else
|
|
printf '%s\n' ' (no changed files)' >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
if ! awk -F ' ' '
|
|
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { next }
|
|
$1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
|
|
$1 == "A" && $2 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
|
|
$1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && $3 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 3 { next }
|
|
{ invalid = 1 }
|
|
END { exit invalid }
|
|
' "$TMP_ROOT/name-status"; then
|
|
printf '%s\n' 'error: CHANGELOG.md may accompany only release-fragment archival; ordinary PRs must add .changes/<unique-name>.md without editing CHANGELOG.md' >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then
|
|
printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2
|
|
exit 1
|
|
fi
|
|
|
|
"$ROOT/scripts/policy/open-source-audit.sh"
|
|
|
|
if ! git show "${MERGE_BASE}:CHANGELOG.md" >"$TMP_ROOT/base-changelog" ||
|
|
! git show "${HEAD_COMMIT}:CHANGELOG.md" >"$TMP_ROOT/head-changelog"; then
|
|
printf '%s\n' 'error: could not read CHANGELOG.md from both commits' >&2
|
|
exit 2
|
|
fi
|
|
|
|
git diff --no-ext-diff --unified=0 \
|
|
"$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md >"$TMP_ROOT/changelog.patch"
|
|
if grep -q '^Binary files ' "$TMP_ROOT/changelog.patch"; then
|
|
printf '%s\n' 'error: CHANGELOG.md must remain a text file' >&2
|
|
exit 1
|
|
fi
|
|
if ! awk '
|
|
/^\+\+\+ / { next }
|
|
/^\+/ {
|
|
line = substr($0, 2)
|
|
if (line ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) {
|
|
bad = 1
|
|
}
|
|
}
|
|
END { exit bad }
|
|
' "$TMP_ROOT/changelog.patch"; then
|
|
printf '%s\n' 'error: changed CHANGELOG additions must not contain TODO/TBD placeholders' >&2
|
|
exit 1
|
|
fi
|
|
|
|
. "$ROOT/scripts/release/release-lib.sh"
|
|
|
|
extract_labeled_section() {
|
|
_els_file="$1"
|
|
_els_wanted="$2"
|
|
awk -v wanted="$_els_wanted" '
|
|
/^## / {
|
|
active = 0
|
|
if (substr($0, 1, 4) == "## [") {
|
|
rest = substr($0, 5)
|
|
close_pos = index(rest, "]")
|
|
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) {
|
|
active = 1
|
|
}
|
|
}
|
|
}
|
|
active { print }
|
|
' "$_els_file"
|
|
}
|
|
|
|
extract_unmanaged_content() {
|
|
_euc_file="$1"
|
|
awk '
|
|
BEGIN { active = 1 }
|
|
/^## / {
|
|
active = 1
|
|
if (substr($0, 1, 4) == "## [") {
|
|
rest = substr($0, 5)
|
|
close_pos = index(rest, "]")
|
|
if (close_pos > 1) {
|
|
active = 0
|
|
}
|
|
}
|
|
}
|
|
active { print }
|
|
' "$_euc_file"
|
|
}
|
|
|
|
label_heading_count() {
|
|
_lhc_file="$1"
|
|
_lhc_wanted="$2"
|
|
awk -v wanted="$_lhc_wanted" '
|
|
/^## / && substr($0, 1, 4) == "## [" {
|
|
rest = substr($0, 5)
|
|
close_pos = index(rest, "]")
|
|
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) count++
|
|
}
|
|
END { print count + 0 }
|
|
' "$_lhc_file"
|
|
}
|
|
|
|
calendar_date_is_valid() {
|
|
_cdiv_date="$1"
|
|
awk -v date="$_cdiv_date" 'BEGIN {
|
|
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) exit 1
|
|
split(date, part, "-")
|
|
year = part[1] + 0
|
|
month = part[2] + 0
|
|
day = part[3] + 0
|
|
if (year < 1 || month < 1 || month > 12 || day < 1) exit 1
|
|
days = 31
|
|
if (month == 4 || month == 6 || month == 9 || month == 11) days = 30
|
|
if (month == 2) {
|
|
days = 28
|
|
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) days = 29
|
|
}
|
|
exit day > days
|
|
}'
|
|
}
|
|
|
|
release_heading_date() {
|
|
_rhd_file="$1"
|
|
_rhd_version="$2"
|
|
awk -v wanted="$_rhd_version" '
|
|
BEGIN { prefix = "## [" wanted "] - " }
|
|
index($0, prefix) == 1 {
|
|
print substr($0, length(prefix) + 1)
|
|
}
|
|
' "$_rhd_file"
|
|
}
|
|
|
|
validate_release_notes() {
|
|
_vrn_file="$1"
|
|
set +e
|
|
awk '
|
|
NR == 1 { next }
|
|
{
|
|
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
|
if ($0 ~ /^- /) bullet = 1
|
|
if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1
|
|
}
|
|
END {
|
|
if (!meaningful || !bullet) exit 43
|
|
if (placeholder) exit 44
|
|
}
|
|
' "$_vrn_file"
|
|
_vrn_status=$?
|
|
set -e
|
|
case "$_vrn_status" in
|
|
0) return 0 ;;
|
|
43) printf '%s\n' 'error: changed release section must contain notes and at least one bullet' >&2 ;;
|
|
44) printf '%s\n' 'error: changed release section still contains TODO/TBD placeholders' >&2 ;;
|
|
*) printf '%s\n' 'error: failed to validate changed release notes' >&2 ;;
|
|
esac
|
|
return "$_vrn_status"
|
|
}
|
|
|
|
extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged"
|
|
extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged"
|
|
if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then
|
|
printf '%s\n' 'error: CHANGELOG validation only permits notes inside Unreleased or versioned release sections' >&2
|
|
exit 1
|
|
fi
|
|
|
|
unreleased_count="$(label_heading_count "$TMP_ROOT/head-changelog" Unreleased)"
|
|
unreleased_exact_count="$(awk '$0 == "## [Unreleased]" { count++ } END { print count + 0 }' \
|
|
"$TMP_ROOT/head-changelog")"
|
|
if [ "$unreleased_count" -ne 1 ] || [ "$unreleased_exact_count" -ne 1 ]; then
|
|
printf 'error: CHANGELOG must contain exactly one heading: ## [Unreleased]\n' >&2
|
|
exit 1
|
|
fi
|
|
|
|
extract_labeled_section "$TMP_ROOT/base-changelog" Unreleased >"$TMP_ROOT/base-unreleased"
|
|
extract_labeled_section "$TMP_ROOT/head-changelog" Unreleased >"$TMP_ROOT/head-unreleased"
|
|
unreleased_changed=0
|
|
if ! cmp -s "$TMP_ROOT/base-unreleased" "$TMP_ROOT/head-unreleased"; then
|
|
unreleased_changed=1
|
|
fi
|
|
|
|
awk '
|
|
/^## / && substr($0, 1, 4) == "## [" {
|
|
rest = substr($0, 5)
|
|
close_pos = index(rest, "]")
|
|
if (close_pos > 1) {
|
|
label = substr(rest, 1, close_pos - 1)
|
|
if (label != "Unreleased") print label
|
|
}
|
|
}
|
|
' "$TMP_ROOT/base-changelog" "$TMP_ROOT/head-changelog" |
|
|
LC_ALL=C sort -u >"$TMP_ROOT/release-labels"
|
|
|
|
changed_release_count=0
|
|
section_index=0
|
|
while IFS= read -r version; do
|
|
[ -n "$version" ] || continue
|
|
section_index=$((section_index + 1))
|
|
base_section="$TMP_ROOT/base-release-$section_index"
|
|
head_section="$TMP_ROOT/head-release-$section_index"
|
|
extract_labeled_section "$TMP_ROOT/base-changelog" "$version" >"$base_section"
|
|
extract_labeled_section "$TMP_ROOT/head-changelog" "$version" >"$head_section"
|
|
if cmp -s "$base_section" "$head_section"; then
|
|
continue
|
|
fi
|
|
|
|
changed_release_count=$((changed_release_count + 1))
|
|
if ! release_channel_for_version "v$version" >/dev/null 2>&1; then
|
|
printf 'error: changed CHANGELOG release heading has an invalid version: %s\n' "$version" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$(label_heading_count "$TMP_ROOT/head-changelog" "$version")" -ne 1 ]; then
|
|
printf 'error: CHANGELOG must contain exactly one well-formed section for %s\n' \
|
|
"$version" >&2
|
|
exit 1
|
|
fi
|
|
if ! validate_release_notes "$head_section"; then
|
|
exit 1
|
|
fi
|
|
release_date="$(release_heading_date "$TMP_ROOT/head-changelog" "$version")"
|
|
if ! calendar_date_is_valid "$release_date"; then
|
|
printf 'error: CHANGELOG section for %s has an invalid calendar date: %s\n' \
|
|
"$version" "$release_date" >&2
|
|
exit 1
|
|
fi
|
|
done <"$TMP_ROOT/release-labels"
|
|
|
|
if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then
|
|
printf '%s\n' 'error: CHANGELOG modification did not change an eligible notes section' >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'CHANGELOG PR check: ok (mode=%s release_sections=%s unreleased_changed=%s)\n' \
|
|
"$VALIDATION_MODE" "$changed_release_count" "$unreleased_changed"
|