mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 11:58:08 +00:00
chore(maint): make PR changelog edits release-only (#92607)
This commit is contained in:
@@ -285,8 +285,10 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Do not edit `CHANGELOG.md` for routine PR maintenance, review follow-up, conflict repair, contributor-branch preparation, tests, docs, or feature/fix PR refreshes. Changelog edits are release-managed only and require an explicit release/changelog task.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Do not add assistant, agent, or non-Codex coauthor/credit trailers to commits or public PR comments unless Val explicitly asks for that attribution.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||
- Do not commit PR-only artifacts such as screenshots under `.github/pr-assets`; attach them to the PR/comment or use an external artifact store instead.
|
||||
|
||||
@@ -18,6 +18,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Missing deps: `pnpm install`, retry once, then report first actionable error.
|
||||
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
|
||||
- Product/docs/UI/changelog wording: "plugin/plugins"; `extensions/` is internal.
|
||||
- Routine PR maintenance, review follow-up, conflict repair, and contributor-branch preparation must not edit `CHANGELOG.md`; release generation owns changelog edits.
|
||||
- Do not add assistant, agent, or non-Codex coauthor/credit trailers on OpenClaw PR commits or public PR comments unless Val explicitly asks for that attribution.
|
||||
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
|
||||
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink; edit `AGENTS.md` only.
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@ source "$script_parent_dir/pr-lib/worktree.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/common.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/changelog.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/gates.sh"
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/push.sh"
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
changelog_helper_root() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
|
||||
}
|
||||
|
||||
changelog_attribution_script() {
|
||||
printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs"
|
||||
}
|
||||
|
||||
normalize_pr_changelog_entries() {
|
||||
local pr="$1"
|
||||
local changelog_path="CHANGELOG.md"
|
||||
|
||||
[ -f "$changelog_path" ] || return 0
|
||||
|
||||
PR_NUMBER_FOR_CHANGELOG="$pr" node <<'EOF_NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const pr = process.env.PR_NUMBER_FOR_CHANGELOG;
|
||||
const path = "CHANGELOG.md";
|
||||
const original = fs.readFileSync(path, "utf8");
|
||||
const lines = original.split("\n");
|
||||
const prPattern = new RegExp(`(?:\\(#${pr}\\)|openclaw#${pr})`, "i");
|
||||
|
||||
function findActiveSectionIndex(arr) {
|
||||
const versionUnreleasedIndex = arr.findIndex((line) =>
|
||||
/^##\s+.+\(\s*unreleased\s*\)\s*$/i.test(line.trim()),
|
||||
);
|
||||
if (versionUnreleasedIndex !== -1) {
|
||||
return versionUnreleasedIndex;
|
||||
}
|
||||
return arr.findIndex((line) => line.trim().toLowerCase() === "## unreleased");
|
||||
}
|
||||
|
||||
function findSectionEnd(arr, start) {
|
||||
for (let i = start + 1; i < arr.length; i += 1) {
|
||||
if (/^## /.test(arr[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return arr.length;
|
||||
}
|
||||
|
||||
function ensureActiveSection(arr) {
|
||||
let activeIndex = findActiveSectionIndex(arr);
|
||||
if (activeIndex !== -1) {
|
||||
return activeIndex;
|
||||
}
|
||||
|
||||
let insertAt = arr.findIndex((line, idx) => idx > 0 && /^## /.test(line));
|
||||
if (insertAt === -1) {
|
||||
insertAt = arr.length;
|
||||
}
|
||||
|
||||
const block = ["## Unreleased", "", "### Changes", ""];
|
||||
if (insertAt > 0 && arr[insertAt - 1] !== "") {
|
||||
block.unshift("");
|
||||
}
|
||||
arr.splice(insertAt, 0, ...block);
|
||||
return findActiveSectionIndex(arr);
|
||||
}
|
||||
|
||||
function contextFor(arr, index) {
|
||||
let major = "";
|
||||
let minor = "";
|
||||
for (let i = index; i >= 0; i -= 1) {
|
||||
const line = arr[i];
|
||||
if (!minor && /^### /.test(line)) {
|
||||
minor = line.trim();
|
||||
}
|
||||
if (/^## /.test(line)) {
|
||||
major = line.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
function ensureSubsection(arr, subsection) {
|
||||
const activeIndex = ensureActiveSection(arr);
|
||||
const activeEnd = findSectionEnd(arr, activeIndex);
|
||||
const desired = subsection && /^### /.test(subsection) ? subsection : "### Changes";
|
||||
for (let i = activeIndex + 1; i < activeEnd; i += 1) {
|
||||
if (arr[i].trim() === desired) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = activeEnd;
|
||||
while (insertAt > activeIndex + 1 && arr[insertAt - 1] === "") {
|
||||
insertAt -= 1;
|
||||
}
|
||||
const block = ["", desired, ""];
|
||||
arr.splice(insertAt, 0, ...block);
|
||||
return insertAt + 1;
|
||||
}
|
||||
|
||||
function sectionTailInsertIndex(arr, subsectionIndex) {
|
||||
let nextHeading = arr.length;
|
||||
for (let i = subsectionIndex + 1; i < arr.length; i += 1) {
|
||||
if (/^### /.test(arr[i]) || /^## /.test(arr[i])) {
|
||||
nextHeading = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = nextHeading;
|
||||
while (insertAt > subsectionIndex + 1 && arr[insertAt - 1] === "") {
|
||||
insertAt -= 1;
|
||||
}
|
||||
return insertAt;
|
||||
}
|
||||
|
||||
const activeHeading = lines[ensureActiveSection(lines)]?.trim() || "## Unreleased";
|
||||
|
||||
const moved = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (!prPattern.test(lines[i])) {
|
||||
continue;
|
||||
}
|
||||
const ctx = contextFor(lines, i);
|
||||
if (ctx.major === activeHeading) {
|
||||
continue;
|
||||
}
|
||||
moved.push({
|
||||
line: lines[i],
|
||||
subsection: ctx.minor || "### Changes",
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
|
||||
if (moved.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const removeIndexes = new Set(moved.map((entry) => entry.index));
|
||||
const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx));
|
||||
|
||||
for (const entry of moved) {
|
||||
const subsectionIndex = ensureSubsection(nextLines, entry.subsection);
|
||||
const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex);
|
||||
|
||||
let nextHeading = nextLines.length;
|
||||
for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) {
|
||||
if (/^### /.test(nextLines[i]) || /^## /.test(nextLines[i])) {
|
||||
nextHeading = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyPresent = nextLines
|
||||
.slice(subsectionIndex + 1, nextHeading)
|
||||
.some((line) => line === entry.line);
|
||||
if (alreadyPresent) {
|
||||
continue;
|
||||
}
|
||||
nextLines.splice(insertAt, 0, entry.line);
|
||||
}
|
||||
|
||||
const updated = nextLines.join("\n");
|
||||
if (updated !== original) {
|
||||
fs.writeFileSync(path, updated);
|
||||
}
|
||||
EOF_NODE
|
||||
}
|
||||
|
||||
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=$(git diff --unified=0 origin/main...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)
|
||||
git diff --unified=0 origin/main...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=$(git diff --unified=0 origin/main...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"
|
||||
}
|
||||
@@ -177,32 +177,6 @@ merge_author_email_candidates() {
|
||||
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
|
||||
}
|
||||
|
||||
pr_contributor_allows_human_trailers() {
|
||||
local contrib="${1:-}"
|
||||
local normalized
|
||||
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
case "$normalized" in
|
||||
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_contributor_coauthor_email() {
|
||||
local contrib="${1:-}"
|
||||
|
||||
if ! pr_contributor_allows_human_trailers "$contrib"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local contrib_id
|
||||
contrib_id=$(gh api "users/$contrib" --jq .id) || return 1
|
||||
printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib"
|
||||
}
|
||||
|
||||
common_repo_root() {
|
||||
if command -v repo_root >/dev/null 2>&1; then
|
||||
repo_root
|
||||
|
||||
@@ -56,22 +56,16 @@ prepare_gates() {
|
||||
if [ -n "$unsupported_changelog_fragments" ]; then
|
||||
echo "Unsupported changelog fragment files detected:"
|
||||
printf '%s\n' "$unsupported_changelog_fragments"
|
||||
echo "Move changelog fragment content into CHANGELOG.md and remove changelog/fragments files."
|
||||
echo "Remove changelog/fragments files. OpenClaw changelog edits are release-managed only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$has_changelog_update" = "true" ]; then
|
||||
normalize_pr_changelog_entries "$pr"
|
||||
validate_changelog_attribution_policy
|
||||
echo "CHANGELOG.md changes are release-managed only. Remove CHANGELOG.md from this PR unless this is an explicit release/changelog task."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$changelog_required" = "true" ]; then
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
validate_changelog_merge_hygiene
|
||||
validate_changelog_entry_for_pr "$pr" "$contrib"
|
||||
else
|
||||
echo "Changelog not required for this changed-file set."
|
||||
fi
|
||||
echo "Changelog not required for this changed-file set."
|
||||
|
||||
local current_head
|
||||
current_head=$(git rev-parse HEAD)
|
||||
@@ -96,7 +90,7 @@ prepare_gates() {
|
||||
|
||||
if [ "$reuse_gates" = "true" ]; then
|
||||
gates_mode="reused_docs_only"
|
||||
echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates."
|
||||
echo "Docs-only delta since last verified head $previous_last_verified_head; reusing prior gates."
|
||||
else
|
||||
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
|
||||
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
|
||||
|
||||
@@ -183,8 +183,6 @@ merge_run() {
|
||||
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
|
||||
local pr_number
|
||||
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
|
||||
local contrib
|
||||
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
|
||||
local is_draft
|
||||
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
@@ -197,15 +195,6 @@ merge_run() {
|
||||
local reviewer_id
|
||||
reviewer_id=$(gh api user --jq .id)
|
||||
|
||||
local contrib_coauthor_email="${COAUTHOR_EMAIL:-}"
|
||||
if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then
|
||||
if contrib_coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
|
||||
:
|
||||
else
|
||||
contrib_coauthor_email=""
|
||||
fi
|
||||
fi
|
||||
|
||||
local reviewer_email_candidates=()
|
||||
local reviewer_email_candidate
|
||||
while IFS= read -r reviewer_email_candidate; do
|
||||
@@ -218,17 +207,11 @@ merge_run() {
|
||||
fi
|
||||
|
||||
local reviewer_email="${reviewer_email_candidates[0]}"
|
||||
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
|
||||
|
||||
{
|
||||
echo "Merged via squash."
|
||||
echo
|
||||
echo "Prepared head SHA: $PREP_HEAD_SHA"
|
||||
if [ -n "$contrib_coauthor_email" ]; then
|
||||
echo "Co-authored-by: $contrib <$contrib_coauthor_email>"
|
||||
fi
|
||||
echo "Co-authored-by: $reviewer <$reviewer_coauthor_email>"
|
||||
echo "Reviewed-by: @$reviewer"
|
||||
} > .local/merge-body.txt
|
||||
|
||||
delete_remote_pr_head_branch_after_merge() {
|
||||
@@ -349,15 +332,6 @@ merge_run() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local commit_body
|
||||
commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message)
|
||||
if [ -n "$contrib_coauthor_email" ]; then
|
||||
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
|
||||
else
|
||||
echo "Skipping PR author co-author trailer check for bot/app author $contrib."
|
||||
fi
|
||||
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; }
|
||||
|
||||
local ok=0
|
||||
local comment_output=""
|
||||
local attempt
|
||||
@@ -368,10 +342,6 @@ merge_run() {
|
||||
echo
|
||||
echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)"
|
||||
echo "- Merge commit: [$merge_sha]($merge_sha_url)"
|
||||
if pr_contributor_allows_human_trailers "$contrib"; then
|
||||
echo
|
||||
echo "Thanks @$contrib!"
|
||||
fi
|
||||
} | gh pr comment "$pr" -F - 2>&1
|
||||
); then
|
||||
ok=1
|
||||
|
||||
@@ -163,12 +163,6 @@ prepare_push() {
|
||||
if [ -z "$contrib" ]; then
|
||||
contrib=$(gh pr view "$pr" --json author --jq .author.login)
|
||||
fi
|
||||
local coauthor_email=""
|
||||
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
|
||||
:
|
||||
else
|
||||
coauthor_email=""
|
||||
fi
|
||||
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Gates passed and push succeeded to branch $PR_HEAD.
|
||||
@@ -185,7 +179,6 @@ EOF_PREP
|
||||
PR_HEAD "$PR_HEAD" \
|
||||
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
|
||||
PREP_HEAD_SHA "$prep_head_sha" \
|
||||
COAUTHOR_EMAIL "$coauthor_email" \
|
||||
> .local/prep.env
|
||||
|
||||
ls -la .local/prep.md .local/prep.env >/dev/null
|
||||
@@ -240,12 +233,6 @@ prepare_sync_head() {
|
||||
if [ -z "$contrib" ]; then
|
||||
contrib=$(gh pr view "$pr" --json author --jq .author.login)
|
||||
fi
|
||||
local coauthor_email=""
|
||||
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
|
||||
:
|
||||
else
|
||||
coauthor_email=""
|
||||
fi
|
||||
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Prep head sync completed to branch $PR_HEAD.
|
||||
@@ -263,7 +250,6 @@ EOF_PREP
|
||||
PR_HEAD "$PR_HEAD" \
|
||||
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
|
||||
PREP_HEAD_SHA "$prep_head_sha" \
|
||||
COAUTHOR_EMAIL "$coauthor_email" \
|
||||
> .local/prep.env
|
||||
|
||||
ls -la .local/prep.md .local/prep.env >/dev/null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Check Changelog Attributions tests cover check changelog attributions script behavior.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
requiresExplicitHumanChangelogThanks,
|
||||
} from "../../scripts/check-changelog-attributions.mjs";
|
||||
|
||||
const changelogScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "changelog.sh");
|
||||
const commonScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "common.sh");
|
||||
const gatesScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "gates.sh");
|
||||
|
||||
@@ -52,32 +51,6 @@ function createRepoWithChangelog(content: string): string {
|
||||
return repo;
|
||||
}
|
||||
|
||||
function validateChangelogEntry(repo: string, contrib: string): string {
|
||||
return run(
|
||||
repo,
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
'source "$OPENCLAW_PR_CHANGELOG_SH"; validate_changelog_entry_for_pr 123 "$OPENCLAW_TEST_CONTRIB"',
|
||||
],
|
||||
{
|
||||
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
||||
OPENCLAW_TEST_CONTRIB: contrib,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validateChangelogAttributionPolicy(repo: string): string {
|
||||
return run(
|
||||
repo,
|
||||
"bash",
|
||||
["-c", 'source "$OPENCLAW_PR_CHANGELOG_SH"; validate_changelog_attribution_policy'],
|
||||
{
|
||||
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("check-changelog-attributions", () => {
|
||||
it("flags forbidden bot, org, and maintainer thanks attributions", () => {
|
||||
const content = [
|
||||
@@ -152,47 +125,16 @@ describe("check-changelog-attributions", () => {
|
||||
expect(requiresExplicitHumanChangelogThanks("")).toBe(false);
|
||||
});
|
||||
|
||||
it("requires explicit human thanks for bot PR changelog entries", () => {
|
||||
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123).");
|
||||
try {
|
||||
let output = "";
|
||||
try {
|
||||
validateChangelogEntry(repo, "dependabot[bot]");
|
||||
} catch (error) {
|
||||
output = String((error as { stdout?: unknown }).stdout ?? error);
|
||||
}
|
||||
expect(output).toContain("must include an explicit human Thanks @handle");
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts explicit human thanks for bot PR changelog entries", () => {
|
||||
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123). Thanks @alice.");
|
||||
try {
|
||||
expect(validateChangelogEntry(repo, "dependabot[bot]")).toContain("explicit thanks");
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-bot forbidden contributors on the no-thanks fallback", () => {
|
||||
const repo = createRepoWithPrChangelogDiff("- Maintainer repair (#123).");
|
||||
try {
|
||||
expect(validateChangelogEntry(repo, "steipete")).toContain("skipping thanks check");
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the shell attribution policy over real changelog content", () => {
|
||||
it("runs the attribution policy CLI over real changelog content", () => {
|
||||
const forbiddenRepo = createRepoWithChangelog(
|
||||
"# Changelog\n\n## Unreleased\n\n### Fixes\n\n- Bot repair. Thanks @dependabot[bot].\n",
|
||||
);
|
||||
try {
|
||||
let output = "";
|
||||
try {
|
||||
validateChangelogAttributionPolicy(forbiddenRepo);
|
||||
run(forbiddenRepo, "node", [
|
||||
path.join(process.cwd(), "scripts/check-changelog-attributions.mjs"),
|
||||
]);
|
||||
} catch (error) {
|
||||
output = String((error as { stderr?: unknown }).stderr ?? error);
|
||||
}
|
||||
@@ -206,53 +148,52 @@ describe("check-changelog-attributions", () => {
|
||||
"# Changelog\n\n## Unreleased\n\n### Fixes\n\n- User fix. Thanks @alice.\n",
|
||||
);
|
||||
try {
|
||||
expect(validateChangelogAttributionPolicy(allowedRepo)).toBe("");
|
||||
expect(
|
||||
run(allowedRepo, "node", [
|
||||
path.join(process.cwd(), "scripts/check-changelog-attributions.mjs"),
|
||||
]),
|
||||
).toBe("");
|
||||
} finally {
|
||||
rmSync(allowedRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs changelog attribution policy from prepare gates when CHANGELOG changes", () => {
|
||||
it("rejects changelog changes from prepare gates", () => {
|
||||
const repo = createRepoWithPrChangelogDiff("- User fix (#123). Thanks @alice.");
|
||||
const callsPath = path.join(repo, "calls.log");
|
||||
mkdirSync(path.join(repo, ".local"));
|
||||
writeFileSync(path.join(repo, ".local", "pr-meta.env"), "PR_AUTHOR=alice\n", "utf8");
|
||||
try {
|
||||
const output = run(
|
||||
repo,
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`
|
||||
let output = "";
|
||||
try {
|
||||
run(
|
||||
repo,
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`
|
||||
set -euo pipefail
|
||||
source "$OPENCLAW_PR_COMMON_SH"
|
||||
source "$OPENCLAW_PR_CHANGELOG_SH"
|
||||
source "$OPENCLAW_PR_GATES_SH"
|
||||
|
||||
enter_worktree() { :; }
|
||||
checkout_prep_branch() { :; }
|
||||
bootstrap_deps_if_needed() { :; }
|
||||
require_artifact() { [ -s "$1" ]; }
|
||||
normalize_pr_changelog_entries() { printf 'normalize\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
||||
validate_changelog_attribution_policy() { printf 'policy\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
||||
validate_changelog_merge_hygiene() { printf 'merge-hygiene\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
||||
validate_changelog_entry_for_pr() { printf 'entry:%s:%s\\n' "$1" "$2" >>"$OPENCLAW_TEST_CALLS"; }
|
||||
run_quiet_logged() { printf 'gate:%s\\n' "$1" >>"$OPENCLAW_TEST_CALLS"; }
|
||||
run_quiet_logged() { echo "unexpected gate: $1"; exit 99; }
|
||||
|
||||
prepare_gates 123
|
||||
`,
|
||||
],
|
||||
{
|
||||
OPENCLAW_PR_COMMON_SH: commonScriptPath,
|
||||
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
||||
OPENCLAW_PR_GATES_SH: gatesScriptPath,
|
||||
OPENCLAW_TEST_CALLS: callsPath,
|
||||
},
|
||||
);
|
||||
const calls = readFileSync(callsPath, "utf8");
|
||||
],
|
||||
{
|
||||
OPENCLAW_PR_COMMON_SH: commonScriptPath,
|
||||
OPENCLAW_PR_GATES_SH: gatesScriptPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
output = String((error as { stdout?: unknown }).stdout ?? error);
|
||||
}
|
||||
|
||||
expect(output).toContain("docs_only=true");
|
||||
expect(calls).toContain("normalize\npolicy\n");
|
||||
expect(output).toContain("CHANGELOG.md changes are release-managed only");
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user