openclaw/scripts/pr-lib/changelog.sh
Peter Steinberger 1abf60d28c
fix(scripts): prevent host tooling drift from blocking PR workflows (#152078)
* fix(scripts): resolve pr tooling from a configurable root and preflight host git

* fix(scripts): preserve PR tooling contracts in dependency checks and fixtures

* test(scripts): match cross-checkout PR identity diagnostics

* fix(scripts): preserve GitHub CLI host selection for PR reads

* test(scripts): inject remote base drift at hosted verification
2026-09-18 14:34:18 -07:00

249 lines
7 KiB
Bash

# shellcheck source=scripts/pr-lib/host-tools.sh
source "${BASH_SOURCE[0]%/*}/host-tools.sh" || return 1
changelog_helper_root() {
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
}
changelog_attribution_script() {
printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs"
}
validate_changelog_attribution_policy() {
node "$(changelog_attribution_script)" CHANGELOG.md
}
changelog_thanks_required_for_contributor() {
local contrib="${1:-}"
[ -n "$contrib" ] || return 1
node "$(changelog_attribution_script)" --is-forbidden-handle "$contrib" && return 1
return 0
}
changelog_explicit_human_thanks_required_for_contributor() {
local contrib="${1:-}"
[ -n "$contrib" ] || return 1
node "$(changelog_attribution_script)" --requires-explicit-human-thanks "$contrib"
}
validate_changelog_entry_for_pr() {
local pr="$1"
local contrib="$2"
local added_lines
added_lines=$(pr_git diff --unified=0 "$PR_MAIN_SHA...HEAD" -- CHANGELOG.md | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
if [ -z "$added_lines" ]; then
echo "CHANGELOG.md is in diff but no added lines were detected."
exit 1
fi
local pr_pattern
pr_pattern="(#$pr|openclaw#$pr)"
local with_pr
with_pr=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" || true)
if [ -z "$with_pr" ]; then
echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))."
exit 1
fi
local diff_file
diff_file=$(mktemp)
pr_git diff --unified=0 "$PR_MAIN_SHA...HEAD" -- CHANGELOG.md > "$diff_file"
if ! awk -v pr_pattern="$pr_pattern" '
BEGIN {
line_no = 0
file_line_count = 0
issue_count = 0
}
FNR == NR {
if ($0 ~ /^@@ /) {
if (match($0, /\+[0-9]+/)) {
line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0
} else {
line_no = 0
}
next
}
if ($0 ~ /^\+\+\+/) {
next
}
if ($0 ~ /^\+/) {
if (line_no > 0) {
added[line_no] = 1
added_text = substr($0, 2)
if (added_text ~ pr_pattern) {
pr_added_lines[++pr_added_count] = line_no
pr_added_text[line_no] = added_text
}
line_no++
}
next
}
if ($0 ~ /^-/) {
next
}
if (line_no > 0) {
line_no++
}
next
}
{
changelog[FNR] = $0
file_line_count = FNR
}
END {
active_release_line = 0
bare_release_line = 0
active_release_name = "unreleased"
for (i = 1; i <= file_line_count; i++) {
if (changelog[i] !~ /^## /) {
continue
}
heading = tolower(changelog[i])
if (heading ~ /^##[[:space:]]+.+\([[:space:]]*unreleased[[:space:]]*\)[[:space:]]*$/) {
active_release_line = i
active_release_name = changelog[i]
break
}
if (heading == "## unreleased" && bare_release_line == 0) {
bare_release_line = i
}
}
if (active_release_line == 0 && bare_release_line != 0) {
active_release_line = bare_release_line
active_release_name = changelog[bare_release_line]
}
for (idx = 1; idx <= pr_added_count; idx++) {
entry_line = pr_added_lines[idx]
release_line = 0
section_line = 0
for (i = entry_line; i >= 1; i--) {
if (section_line == 0 && changelog[i] ~ /^### /) {
section_line = i
continue
}
if (changelog[i] ~ /^## /) {
release_line = i
break
}
}
if (release_line == 0 || release_line != active_release_line) {
printf "CHANGELOG.md PR-linked entry must be in %s: line %d: %s\n", active_release_name, entry_line, pr_added_text[entry_line]
issue_count++
continue
}
if (section_line == 0) {
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
section_name = changelog[section_line]
next_heading = file_line_count + 1
for (i = entry_line + 1; i <= file_line_count; i++) {
if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) {
next_heading = i
break
}
}
for (i = entry_line + 1; i < next_heading; i++) {
line_text = changelog[i]
if (line_text ~ /^[[:space:]]*$/) {
continue
}
if (i in added) {
continue
}
printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line]
printf "Found existing non-added line below it at line %d: %s\n", i, line_text
issue_count++
break
}
}
if (issue_count > 0) {
print "Move this PR changelog entry to the end of its section (just before the next heading)."
exit 1
}
}
' "$diff_file" CHANGELOG.md; then
rm -f "$diff_file"
exit 1
fi
rm -f "$diff_file"
echo "changelog placement validated: PR-linked entries are appended at section tail"
if changelog_thanks_required_for_contributor "$contrib"; then
local with_pr_and_thanks
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Fi "thanks @$contrib" || true)
if [ -z "$with_pr_and_thanks" ]; then
echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line."
exit 1
fi
echo "changelog validated: found PR #$pr + thanks @$contrib"
return 0
fi
if ! changelog_explicit_human_thanks_required_for_contributor "$contrib"; then
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
return 0
fi
local with_pr_and_any_thanks
with_pr_and_any_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Ei '(^|[[:space:]])thanks[[:space:]]+@' || true)
if [ -z "$with_pr_and_any_thanks" ]; then
echo "CHANGELOG.md update for bot/app/non-creditable author $contrib must include an explicit human Thanks @handle on the PR #$pr entry line."
echo "Choose the credited original contributor, or stop for maintainer input if authorship is unclear."
exit 1
fi
echo "changelog validated: found PR #$pr + explicit thanks for bot/app/non-creditable author $contrib"
}
validate_changelog_merge_hygiene() {
local diff
diff=$(pr_git diff --unified=0 "$PR_MAIN_SHA...HEAD" -- CHANGELOG.md)
local removed_lines
removed_lines=$(printf '%s\n' "$diff" | awk '
/^---/ { next }
/^-/ { print substr($0, 2) }
')
if [ -z "$removed_lines" ]; then
return 0
fi
local removed_refs
removed_refs=$(printf '%s\n' "$removed_lines" | grep -Eo '#[0-9]+' | sort -u || true)
if [ -z "$removed_refs" ]; then
return 0
fi
local added_lines
added_lines=$(printf '%s\n' "$diff" | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
local ref
while IFS= read -r ref; do
[ -z "$ref" ] && continue
if ! printf '%s\n' "$added_lines" | grep -Fq "$ref"; then
echo "CHANGELOG.md drops existing entry reference $ref without re-adding it."
echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)."
exit 1
fi
done <<<"$removed_refs"
echo "changelog merge hygiene validated: no dropped PR references"
}