🐛 fix(commit-message): enforce one rule owner across skill and CI
CI ran .gitea/ci/commit_message_lint.py, an independent reimplementation that parsed the type/emoji mapping out of docs/common/commit_message.md. Its scope pattern accepted fix(-a), fix(a-), fix(a--b) and fix(_), and it had no subject length check, so the gate that actually blocks merges enforced weaker rules than the Validation this skill reports. The CI entry is now a wrapper that locates the skill validator and delegates to it with no arguments; commit_policy.json becomes the only rule source and explanatory docs stop being a machine policy input. The validator also learns the workflow_run event, whose payload carries neither a PR title nor a before/after range. An upstream pull_request now validates <integration-branch>..head_sha instead of HEAD alone, taking the branch name from COMMIT_LINT_MAIN_BRANCH, and degrades to the upstream head commit with a WARN rather than guessing a base. Alongside: --help now documents the no-argument CI mode it had always supported silently, CI wiring detail moves to references/ci-wiring.md, and the description gains negative boundaries. test/test_commit_message_policy.py asserts policy/spec-table equality and uses ast to assert the CI entry imports no regex and reads no file, so a second implementation cannot reappear unnoticed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,38 @@ HEADER_RE = re.compile(
|
||||
r":\s+(?P<text>.+)$"
|
||||
)
|
||||
|
||||
HELP_EPILOG = """\
|
||||
三种模式(互斥;未给出 --subject / --message-file 时进入 CI 输入校验):
|
||||
|
||||
--subject "<候选>" 校验单个候选主题;交互式起草提交信息时用这一种
|
||||
--message-file <path> 校验提交信息文件的首行;body/footer 不做机械校验
|
||||
(无参数) CI 输入校验:从事件 payload 取回全部待校验主题
|
||||
|
||||
策略来源固定优先级:--policy > $COMMIT_POLICY_PATH > 本脚本旁的
|
||||
references/commit_policy.json。策略缺失、损坏或 schema_version 不支持时报部署
|
||||
错误,不退回任何内置约定。本脚本是提交信息规则的唯一实现;说明性文档不是机器
|
||||
策略来源。
|
||||
|
||||
CI 输入校验按事件展开:
|
||||
|
||||
pull_request* pull_request.title 加 base.sha..head.sha 的完整提交范围
|
||||
push before..after 完整范围;before 为零对象时按分支图计算新增提交
|
||||
workflow_run 上游为 pull_request* 时,取 <集成分支>..workflow_run.head_sha
|
||||
的完整范围;其它上游事件只校验 workflow_run.head_sha 一条
|
||||
其它 / 本地 回退 HEAD 一条
|
||||
|
||||
集成分支名取 $COMMIT_LINT_MAIN_BRANCH,默认 main;依次尝试
|
||||
refs/remotes/origin/<名字> 与 refs/heads/<名字>。无法解析、或上游 head_branch
|
||||
就是集成分支时,退化为只校验上游 head 一条并打印 WARN,不猜测 base。
|
||||
|
||||
workflow_run payload 不含 PR 标题,该架构下标题不参与校验;完整提交范围校验取代
|
||||
它,且强度更高。
|
||||
|
||||
已识别事件缺失或损坏 payload 时失败;shallow repository 一律失败。
|
||||
|
||||
退出码:0 全部通过;1 存在不合规主题;2 策略、参数或输入错误。
|
||||
"""
|
||||
|
||||
|
||||
class PolicyError(ValueError):
|
||||
pass
|
||||
@@ -233,7 +265,11 @@ def _read_message_file(path: Path) -> str:
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate commit-message subjects")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate commit-message subjects",
|
||||
epilog=HELP_EPILOG,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--policy", help="path to a versioned commit policy JSON")
|
||||
candidates = parser.add_mutually_exclusive_group()
|
||||
candidates.add_argument("--subject", help="single commit subject to validate")
|
||||
@@ -246,7 +282,11 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||
|
||||
|
||||
def _requires_event_payload(event_name: str) -> bool:
|
||||
return event_name == "push" or event_name.startswith("pull_request")
|
||||
return (
|
||||
event_name == "push"
|
||||
or event_name == "workflow_run"
|
||||
or event_name.startswith("pull_request")
|
||||
)
|
||||
|
||||
|
||||
def _load_event_payload() -> tuple[str, dict[str, object] | None]:
|
||||
@@ -335,19 +375,9 @@ def _require_complete_git_history(context: str) -> None:
|
||||
raise InputError(f"cannot determine repository depth for {context}")
|
||||
|
||||
|
||||
def _zero_before_subjects(
|
||||
payload: dict[str, object], after: str
|
||||
) -> list[tuple[str, str]]:
|
||||
target_ref = payload.get("ref")
|
||||
if not isinstance(target_ref, str) or not target_ref.startswith("refs/heads/"):
|
||||
raise InputError("zero-before push event requires a refs/heads/* ref")
|
||||
def _other_branch_refs(branch_name: str, context: str) -> list[str]:
|
||||
"""Non-symbolic branch refs that are not `branch_name` on any remote."""
|
||||
try:
|
||||
check_ref = subprocess.run(
|
||||
["git", "check-ref-format", target_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
refs_result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
@@ -361,29 +391,50 @@ def _zero_before_subjects(
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc
|
||||
if check_ref.returncode != 0:
|
||||
raise InputError(f"invalid zero-before push ref: {target_ref}")
|
||||
raise InputError(f"cannot inspect refs for {context}: {exc}") from exc
|
||||
if refs_result.returncode != 0:
|
||||
detail = refs_result.stderr.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise InputError(f"cannot inspect refs for zero-before push{suffix}")
|
||||
raise InputError(f"cannot inspect refs for {context}{suffix}")
|
||||
|
||||
branch_name = target_ref.removeprefix("refs/heads/")
|
||||
own_ref = f"refs/heads/{branch_name}"
|
||||
exclusions: list[str] = []
|
||||
for line in refs_result.stdout.splitlines():
|
||||
ref_name, separator, symbolic_target = line.partition("\0")
|
||||
if not separator or not ref_name:
|
||||
raise InputError("invalid git ref output for zero-before push")
|
||||
raise InputError(f"invalid git ref output for {context}")
|
||||
if symbolic_target:
|
||||
continue
|
||||
if ref_name == target_ref:
|
||||
if ref_name == own_ref:
|
||||
continue
|
||||
if ref_name.startswith("refs/remotes/"):
|
||||
remote_parts = ref_name.split("/", 3)
|
||||
if len(remote_parts) == 4 and remote_parts[3] == branch_name:
|
||||
continue
|
||||
exclusions.append(ref_name)
|
||||
return exclusions
|
||||
|
||||
|
||||
def _zero_before_subjects(
|
||||
payload: dict[str, object], after: str
|
||||
) -> list[tuple[str, str]]:
|
||||
target_ref = payload.get("ref")
|
||||
if not isinstance(target_ref, str) or not target_ref.startswith("refs/heads/"):
|
||||
raise InputError("zero-before push event requires a refs/heads/* ref")
|
||||
try:
|
||||
check_ref = subprocess.run(
|
||||
["git", "check-ref-format", target_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc
|
||||
if check_ref.returncode != 0:
|
||||
raise InputError(f"invalid zero-before push ref: {target_ref}")
|
||||
|
||||
branch_name = target_ref.removeprefix("refs/heads/")
|
||||
exclusions = _other_branch_refs(branch_name, "zero-before push")
|
||||
|
||||
revisions = [after]
|
||||
if exclusions:
|
||||
@@ -417,6 +468,73 @@ def _push_range_subjects(payload: dict[str, object]) -> list[tuple[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
def _integration_branch() -> tuple[str, str | None]:
|
||||
"""The configured integration branch name and its first resolvable ref."""
|
||||
name = (os.getenv("COMMIT_LINT_MAIN_BRANCH") or "").strip() or "main"
|
||||
for ref in (f"refs/remotes/origin/{name}", f"refs/heads/{name}"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "--quiet", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise InputError(f"cannot resolve {ref}: {exc}") from exc
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return name, ref
|
||||
return name, None
|
||||
|
||||
|
||||
def _workflow_run_subjects(payload: dict[str, object]) -> list[tuple[str, str]]:
|
||||
"""Subjects for a run triggered by another run's completion.
|
||||
|
||||
A workflow_run payload carries no PR title and no before/after range, so the
|
||||
upstream head SHA is the only anchor. For an upstream pull_request the range
|
||||
is taken against the integration branch; every other case validates the
|
||||
upstream head commit alone rather than guessing a base.
|
||||
"""
|
||||
run = payload.get("workflow_run")
|
||||
if not isinstance(run, dict):
|
||||
raise InputError("workflow_run event payload.workflow_run must be an object")
|
||||
head_sha = run.get("head_sha")
|
||||
if not isinstance(head_sha, str) or GIT_OBJECT_ID_RE.fullmatch(head_sha) is None:
|
||||
raise InputError("workflow_run.head_sha must be a full Git object ID")
|
||||
upstream_event = run.get("event")
|
||||
if not isinstance(upstream_event, str) or not upstream_event:
|
||||
raise InputError("workflow_run.event must be a non-empty string")
|
||||
_require_complete_git_history("workflow_run event")
|
||||
|
||||
label_prefix = f"workflow_run({upstream_event}).commit"
|
||||
head_only = f"validating only upstream head commit {head_sha[:7]}"
|
||||
if upstream_event.startswith("pull_request"):
|
||||
head_branch = run.get("head_branch")
|
||||
if not isinstance(head_branch, str) or not head_branch:
|
||||
raise InputError("workflow_run.head_branch must be a non-empty string")
|
||||
main_name, main_ref = _integration_branch()
|
||||
if main_ref is None:
|
||||
_eprint(f"WARN: no {main_name} ref to compare against; {head_only}")
|
||||
elif head_branch == main_name:
|
||||
_eprint(f"WARN: upstream head_branch is {main_name}; {head_only}")
|
||||
else:
|
||||
revision_range = f"{main_ref}..{head_sha}"
|
||||
subjects = _git_log_subjects(
|
||||
[head_sha, "--not", main_ref],
|
||||
f"workflow_run pull-request range {revision_range}",
|
||||
allow_empty=True,
|
||||
label_prefix=label_prefix,
|
||||
)
|
||||
if subjects:
|
||||
return subjects
|
||||
_eprint(f"WARN: {revision_range} is empty; {head_only}")
|
||||
|
||||
return _git_log_subjects(
|
||||
["-1", head_sha],
|
||||
f"workflow_run head commit {head_sha[:7]}",
|
||||
label_prefix=label_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
|
||||
event_name, payload = _load_event_payload()
|
||||
subjects: list[tuple[str, str]] = []
|
||||
@@ -475,8 +593,14 @@ def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
|
||||
label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit"
|
||||
subjects.append((label, subject))
|
||||
range_subjects = _push_range_subjects(payload)
|
||||
# The loop above only asserts payload shape; the Git range is authority.
|
||||
return event_name, range_subjects
|
||||
|
||||
if event_name == "workflow_run":
|
||||
if not isinstance(payload, dict):
|
||||
raise InputError("workflow_run event payload is required")
|
||||
return event_name, _workflow_run_subjects(payload)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", "HEAD"],
|
||||
|
||||
Reference in New Issue
Block a user