Files
cacti/.github/scripts/check-changelog.py
Thomas Vincent 13adf317c0 chore: stop pull requests conflicting each other on shared files (#7798)
* chore: resolve concurrent CHANGELOG appends with a union merge

Every PR lands on the same two anchors, so merging one reliably conflicts
the others on CHANGELOG alone.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
(cherry picked from commit 6bc8e63ecb)

* feat(changelog): take entries as fragment files instead of CHANGELOG edits

Every pull request edited one of two lines, so merging one conflicted the
rest. A file per change gives concurrent PRs nothing to collide over.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
(cherry picked from commit a8a0bfa29a)

* chore(security): drop line numbers from the tracked sink baselines

Both verifiers already strip the line before comparing, so the tracked
numbers carried no signal and rewrote 212 lines of baseline on any edit that
shifted code, conflicting every open pull request.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
(cherry picked from commit ccfd3d374b)

* fix(ci): rebase stale pull request branches instead of merging them

A merge update adds a commit per run, so branches accumulate snapshots of
old develop and re-conflict on every shared file they carry.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
(cherry picked from commit e9e33c77cd)

* test: match the escaping call rather than the exact source line

The literal assertions here required the uncast form while
PhpBinaryPathNullCoercionTest required the cast, so the two could not both
pass and whichever landed second broke the other.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
(cherry picked from commit 98cfc84756)

* fix(changelog): handle security entries in fragments and validation

Review caught the missing type. Security entries also take GHSA identifiers
and open a release section ahead of issue and feature entries, none of which
the first pass handled.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: require Composer-managed dependencies

Prevent agents from committing third-party package trees and require composer.json plus composer.lock updates instead.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-08-25 21:10:50 -07:00

131 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate the repository CHANGELOG grouping and entry format."""
from __future__ import annotations
import re
import os
import subprocess
import sys
from pathlib import Path
RELEASE_HEADING = re.compile(r"^(?:\d+\.\d+\.\d+(?:-[\w.]+)?|\d+\.\d+\.x)$")
ENTRY = re.compile(r"^-(issue|feature|security)(?:#[0-9A-Za-z-]+)?:\s+\S.*$")
RANK_NAMES = {0: "security", 1: "issue", 2: "feature"}
NUMBERED_ENTRY = re.compile(r"^-(?:issue|feature)#\d+:|^-security#(?:\d+|GHSA-[0-9A-Za-z-]+):")
FRAGMENT = re.compile(r"^(\d+|GHSA-[0-9A-Za-z-]+)\.(issue|feature|security)$")
def check_fragments(directory: Path) -> list[str]:
"""Validates changelog.d fragments, which replace direct CHANGELOG edits."""
errors: list[str] = []
if not directory.is_dir():
return errors
for item in sorted(directory.iterdir()):
if item.name == "README.md" or item.name.startswith("."):
continue
if not item.is_file():
errors.append(f"{item}: changelog.d holds fragment files only")
continue
match = FRAGMENT.fullmatch(item.name)
if match is None:
errors.append(f"{item}: fragment must be named <number>.issue, <number>.feature, or <number|GHSA-id>.security")
continue
if match.group(2) != "security" and not match.group(1).isdigit():
errors.append(f"{item}: only a security fragment may use a GHSA identifier")
continue
text = item.read_text(encoding="utf-8")
body = text.strip()
if not body:
errors.append(f"{item}: fragment is empty")
elif len(body.splitlines()) > 1:
errors.append(f"{item}: fragment must be a single line")
elif body.startswith("-"):
errors.append(f"{item}: fragment holds the description only, without the -issue or -feature prefix")
return errors
def main() -> int:
path = Path("CHANGELOG")
if not path.is_file():
print("CHANGELOG is missing", file=sys.stderr)
return 1
lines = path.read_text(encoding="utf-8").splitlines()
try:
start = lines.index("1.3.0-dev")
except ValueError:
print("CHANGELOG is missing the 1.3.0-dev section", file=sys.stderr)
return 1
end = len(lines)
for index in range(start + 1, len(lines)):
if RELEASE_HEADING.fullmatch(lines[index]):
end = index
break
errors: list[str] = check_fragments(Path("changelog.d"))
highest_rank = 0
for index in range(start + 1, end):
line = lines[index].rstrip()
number = index + 1
if not line:
continue
if not line.startswith(("-issue", "-feature", "-security")):
if line.startswith("-"):
errors.append(f"line {number}: malformed CHANGELOG entry: {line}")
continue
if not ENTRY.fullmatch(line):
errors.append(f"line {number}: malformed CHANGELOG entry: {line}")
continue
if line.startswith("-security"):
kind, rank = "security", 0
elif line.startswith("-issue"):
kind, rank = "issue", 1
else:
kind, rank = "feature", 2
if rank < highest_rank:
errors.append(f"line {number}: {kind} entry appears after {RANK_NAMES[highest_rank]} entries")
else:
highest_rank = rank
if end < len(lines) and lines[end - 1].strip():
errors.append(f"line {end + 1}: release headings must be separated by a blank line")
# Existing releases contain legacy unnumbered entries. New entries must be
# traceable to an issue or pull request, so enforce that only on PR additions.
base_ref = os.environ.get("GITHUB_BASE_REF")
if base_ref:
result = subprocess.run(
["git", "diff", "--unified=0", f"origin/{base_ref}...HEAD", "--", "CHANGELOG"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
for added in result.stdout.splitlines():
if added.startswith(("+++", "---")):
continue
line = added[1:] if added.startswith("+") else ""
if line.startswith(("-issue", "-feature")) and not NUMBERED_ENTRY.match(line):
errors.append(f"new CHANGELOG entries must include an issue or pull-request number: {line}")
if errors:
print("CHANGELOG validation failed:", file=sys.stderr)
print("\n".join(f"- {error}" for error in errors), file=sys.stderr)
return 1
print("CHANGELOG format is valid")
return 0
if __name__ == "__main__":
raise SystemExit(main())