Files
dingtalk-workspace-cli/scripts/policy/check-changelog-pr.sh

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"