From 002f8f0ba1c970b8d7d220cbb9606020084a55cb Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:07:33 -0700 Subject: [PATCH] ci: enforce mappable co-author credit Add AUTHOR_MAP plus a lightweight co-author trailer checker so harvested commits use numeric GitHub noreply identities, reject bot/tool trailers, and require machine-readable credit when a commit says it was harvested from a PR. Also normalize the local unpushed v0.9 harvest range so existing contributor authors/trailers for HUQIANTAO, Implementist, jrcjrcc, xyuai, cyq1017, idling11, and shenjackyuanjie use GitHub-mappable identities before the branch is published. Validation: python3 scripts/check-coauthor-trailers.py --author-map .github/AUTHOR_MAP --range origin/main..HEAD --check-authors; python3 -m py_compile scripts/check-coauthor-trailers.py; ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml")'; git diff --check; negative in-process validation for raw email, missing harvested credit, and bot author cases. --- .github/AUTHOR_MAP | 90 +++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 18 +++ CHANGELOG.md | 3 + CONTRIBUTING.md | 6 +- docs/AGENT_ETHOS.md | 6 + docs/V0_9_0_EXECUTION_MAP.md | 1 + scripts/check-coauthor-trailers.py | 245 +++++++++++++++++++++++++++++ 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 .github/AUTHOR_MAP create mode 100644 scripts/check-coauthor-trailers.py diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP new file mode 100644 index 00000000..26bca8cf --- /dev/null +++ b/.github/AUTHOR_MAP @@ -0,0 +1,90 @@ +# Contributor credit identity map. +# +# Format: +# alias = Display Name +# +# The right-hand side must use GitHub's numeric noreply address so harvested +# co-author credit lands in the contributor graph. The left-hand side may be a +# GitHub login, old-style noreply address, raw email from a contributor commit, +# or local machine email seen in older harvested history. + +hmbown = Hmbown <101357273+Hmbown@users.noreply.github.com> +reidliu41 = reidliu41 <61492567+reidliu41@users.noreply.github.com> +reid201711@gmail.com = reidliu41 <61492567+reidliu41@users.noreply.github.com> +HUQIANTAO = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com> +h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +axobase001 = axobase001 <138223345+axobase001@users.noreply.github.com> +donglovejava = donglovejava <211940267+donglovejava@users.noreply.github.com> +Oliver-ZPLiu = Oliver-ZPLiu <47081637+Oliver-ZPLiu@users.noreply.github.com> +idling11 = idling11 <8055620+idling11@users.noreply.github.com> +Hanmiao Li = idling11 <8055620+idling11@users.noreply.github.com> +894876246@qq.com = idling11 <8055620+idling11@users.noreply.github.com> +angziii = angziii <177907677+angziii@users.noreply.github.com> +aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +encyc = encyc <62669951+encyc@users.noreply.github.com> +Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com> +cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com> +cyq = cyq1017 <61975706+cyq1017@users.noreply.github.com> +15000851237@163.com = cyq1017 <61975706+cyq1017@users.noreply.github.com> +zlh124 = zlh124 <56312993+zlh124@users.noreply.github.com> +THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com> +nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com> +Liu-Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +liuwenchang.x@qq.com = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +JiarenWang = JiarenWang <33421508+JiarenWang@users.noreply.github.com> +wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com> +pengyou200902 = pengyou200902 <35026241+pengyou200902@users.noreply.github.com> +linzhiqin2003 = linzhiqin2003 <123250980+linzhiqin2003@users.noreply.github.com> +LING71671 = LING71671 <231181387+LING71671@users.noreply.github.com> +JasonOA888 = JasonOA888 <101583541+JasonOA888@users.noreply.github.com> +Inference1 = Inference1 <68734681+Inference1@users.noreply.github.com> +hongqitai = hongqitai <188678175+hongqitai@users.noreply.github.com> +gordonlu = gordonlu <3125629+gordonlu@users.noreply.github.com> +gaord = gaord <9567937+gaord@users.noreply.github.com> +Ben Gao = gaord <9567937+gaord@users.noreply.github.com> +bengao168@msn.com = gaord <9567937+gaord@users.noreply.github.com> +zhuangbiaowei = zhuangbiaowei <93194+zhuangbiaowei@users.noreply.github.com> +yuanchenglu = yuanchenglu <4088730+yuanchenglu@users.noreply.github.com> +Vishnu1837 = Vishnu1837 <104626273+Vishnu1837@users.noreply.github.com> +sximelon = sximelon <15710511+sximelon@users.noreply.github.com> +Sskift = Sskift <163287349+Sskift@users.noreply.github.com> +New2Niu = New2Niu <19551155+New2Niu@users.noreply.github.com> +mvanhorn = mvanhorn <455140+mvanhorn@users.noreply.github.com> +MengZ-super = MengZ-super <121712068+MengZ-super@users.noreply.github.com> +membphis = membphis <6814606+membphis@users.noreply.github.com> +LeoAlex0 = LeoAlex0 <31839998+LeoAlex0@users.noreply.github.com> +Lee-take = Lee-take <210963840+Lee-take@users.noreply.github.com> +lbcheng888 = lbcheng888 <6716643+lbcheng888@users.noreply.github.com> +kunpeng-ai-lab = kunpeng-ai-lab <16793595+kunpeng-ai-lab@users.noreply.github.com> +elowen53 = elowen53 <88364845+elowen53@users.noreply.github.com> +Elowen = elowen53 <88364845+elowen53@users.noreply.github.com> +xrnc@outlook.com = elowen53 <88364845+elowen53@users.noreply.github.com> +CrepuscularIRIS = CrepuscularIRIS <126939795+CrepuscularIRIS@users.noreply.github.com> +chnjames = chnjames <44110547+chnjames@users.noreply.github.com> +ChaceLyee2101 = ChaceLyee2101 <95995339+ChaceLyee2101@users.noreply.github.com> +AresNing = AresNing <49557311+AresNing@users.noreply.github.com> + +shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +shenjack = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +3695888@qq.com = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +xyuai = xyuai <281015099+xyuai@users.noreply.github.com> +Implementist = Implementist <24910011+Implementist@users.noreply.github.com> +implecao = Implementist <24910011+Implementist@users.noreply.github.com> +yuyuyu4993@qq.com = Implementist <24910011+Implementist@users.noreply.github.com> +jrcjrcc = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +jrcjrcc@users.noreply.github.com = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +RefuseOdd = RefuseOdd <192543033+RefuseOdd@users.noreply.github.com> +wywsoor = wywsoor <26341601+wywsoor@users.noreply.github.com> +hsdbeebou = hsdbeebou <284843096+hsdbeebou@users.noreply.github.com> +tdccccc = tdccccc <79492752+tdccccc@users.noreply.github.com> +greyfreedom = greyfreedom <11493871+greyfreedom@users.noreply.github.com> +greyfreedom@163.com = greyfreedom <11493871+greyfreedom@users.noreply.github.com> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index db22692b..f5ab2949 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,3 +11,4 @@ - [ ] Updated docs or comments as needed - [ ] Added or updated tests where relevant - [ ] Verified TUI behavior manually if UI changes +- [ ] Harvested/co-authored credit uses a GitHub numeric noreply address diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55d51832..7fafe26e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -50,6 +52,22 @@ jobs: run: cargo clippy --workspace --all-features --locked -- -D warnings - name: Check provider registry drift run: python3 scripts/check-provider-registry.py + - name: Check harvested contributor credit + if: github.event_name != 'schedule' + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + git fetch --no-tags origin "${{ github.base_ref }}" + RANGE="origin/${{ github.base_ref }}..HEAD" + elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + RANGE="${{ github.event.before }}..${{ github.sha }}" + else + RANGE="HEAD~1..HEAD" + fi + python3 scripts/check-coauthor-trailers.py \ + --author-map .github/AUTHOR_MAP \ + --range "$RANGE" \ + --check-authors - name: Linux clippy location run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches." diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af57fe8..5ed859a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 explicit Rustls ring-provider installation for the no-provider TLS build, and OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, execpolicy Starlark parsing, and self-update surfaces. +- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested + commits use GitHub-mappable numeric noreply identities instead of `.local`, + placeholder, bot/tool, or raw third-party emails. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66328675..7f8d3c67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,8 +98,12 @@ When this happens: - If the maintainer copies or adapts your code, the harvested commit also keeps attribution with the original author identity when possible: either by preserving the commit author on a cherry-pick or by adding a - `Co-authored-by: Name ` trailer from the original PR commit. This is + `Co-authored-by: Name ` trailer. This is what lets GitHub's contribution surfaces recognize more than prose credit. + Maintainers should use `.github/AUTHOR_MAP`, or run + `gh api users/ --jq '"\(.id)+\(.login)@users.noreply.github.com"'`, + rather than copying raw, `.local`, or old-style noreply emails from a + contributor's machine. - The `CHANGELOG.md` entry for the next release credits you by handle. - The auto-close workflow closes your PR with a templated thank-you and a link to the commit on `main`. diff --git a/docs/AGENT_ETHOS.md b/docs/AGENT_ETHOS.md index 156b95da..73a3507a 100644 --- a/docs/AGENT_ETHOS.md +++ b/docs/AGENT_ETHOS.md @@ -23,6 +23,12 @@ could not cover by ourselves. issues, keep author/co-author attribution where possible, add `Harvested from PR #N by @handle`, and credit the contributor in the changelog or release notes. +- Make credit machine-readable. If a harvested commit cannot preserve the + contributor as the author, add a `Co-authored-by` trailer with the GitHub + numeric noreply address from `.github/AUTHOR_MAP` or + `gh api users/ --jq '"\(.id)+\(.login)@users.noreply.github.com"'`. + Do not use `.local`, placeholder, bot/tool, or raw third-party emails for + human contributor credit. - Deferral is a maintainer action, not a dismissal. If a PR or issue is not ready, say what is blocked, what evidence would change the decision, and which part of the work remains valuable. diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 08d69777..d4cd7789 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -43,6 +43,7 @@ harvest/stewardship commits: | #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | +| Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | diff --git a/scripts/check-coauthor-trailers.py b/scripts/check-coauthor-trailers.py new file mode 100644 index 00000000..527c204f --- /dev/null +++ b/scripts/check-coauthor-trailers.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Validate that harvested contributor credit is GitHub-mappable. + +The check is intentionally scoped to new commits. Historical commits may carry +raw or local emails, but new harvested commits should use GitHub's numeric +`id+login@users.noreply.github.com` address so co-author credit lands in the +contributor graph. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_AUTHOR_MAP = ROOT / ".github" / "AUTHOR_MAP" + +IDENTITY_RE = re.compile(r"^\s*(?P.+?)\s*<(?P[^<>]+)>\s*$") +CANONICAL_NOREPLY_RE = re.compile( + r"^[0-9]+\+[^@\s]+@users\.noreply\.github\.com$", re.IGNORECASE +) +COAUTHOR_RE = re.compile( + r"^Co-authored-by:\s*(?P.*?)\s*<(?P[^<>]+)>\s*$", + re.IGNORECASE | re.MULTILINE, +) +HARVEST_RE = re.compile(r"Harvested from PR #[0-9]+ by @([A-Za-z0-9-]+)") + +BOT_EMAILS = { + "codex@local", + "codex@example.com", + "cursoragent@cursor.com", + "noreply@anthropic.com", +} +BOT_NAMES = ("claude", "codex", "cursor") + + +@dataclass(frozen=True) +class Identity: + name: str + email: str + + def trailer(self) -> str: + return f"Co-authored-by: {self.name} <{self.email}>" + + def author(self) -> str: + return f"{self.name} <{self.email}>" + + +@dataclass(frozen=True) +class Commit: + sha: str + author_name: str + author_email: str + subject: str + body: str + + +def norm_key(value: str) -> str: + return value.strip().lower() + + +def github_login_from_noreply(email: str) -> str | None: + if not CANONICAL_NOREPLY_RE.match(email): + return None + local = email.split("@", 1)[0] + return local.split("+", 1)[1] + + +def parse_identity(raw: str, context: str) -> Identity: + match = IDENTITY_RE.match(raw) + if not match: + raise ValueError(f"{context}: expected 'Name '") + identity = Identity(match.group("name").strip(), match.group("email").strip()) + if not CANONICAL_NOREPLY_RE.match(identity.email): + raise ValueError( + f"{context}: right-hand email must be numeric GitHub noreply, got {identity.email}" + ) + return identity + + +def load_author_map(path: Path) -> dict[str, Identity]: + aliases: dict[str, Identity] = {} + for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + if "=" not in line: + raise ValueError(f"{path}:{lineno}: expected 'alias = Name '") + alias, raw_identity = [part.strip() for part in line.split("=", 1)] + identity = parse_identity(raw_identity, f"{path}:{lineno}") + key = norm_key(alias) + if key in aliases and aliases[key] != identity: + raise ValueError(f"{path}:{lineno}: duplicate alias {alias!r}") + aliases[key] = identity + aliases.setdefault(norm_key(identity.email), identity) + aliases.setdefault(norm_key(identity.name), identity) + if login := github_login_from_noreply(identity.email): + aliases.setdefault(norm_key(login), identity) + return aliases + + +def git_log(commit_range: str) -> list[Commit]: + try: + raw = subprocess.check_output( + [ + "git", + "log", + "--format=%H%x00%an%x00%ae%x00%s%x00%B%x1e", + commit_range, + ], + cwd=ROOT, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"failed to read git range {commit_range!r}: {exc}") from exc + + commits: list[Commit] = [] + for record in raw.split("\x1e"): + if not record.strip(): + continue + parts = record.split("\x00", 4) + if len(parts) != 5: + raise RuntimeError("failed to parse git log output") + commits.append(Commit(*parts)) + return commits + + +def is_bot_identity(name: str, email: str) -> bool: + lowered_name = name.strip().lower() + lowered_email = email.strip().lower() + return lowered_email in BOT_EMAILS or any( + lowered_name == bot or lowered_name.startswith(f"{bot} ") for bot in BOT_NAMES + ) + + +def lookup_identity(aliases: dict[str, Identity], *values: str) -> Identity | None: + for value in values: + identity = aliases.get(norm_key(value)) + if identity is not None: + return identity + return None + + +def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: bool) -> list[str]: + errors: list[str] = [] + for commit in commits: + prefix = f"{commit.sha[:10]} {commit.subject}" + coauthors = [ + Identity(match.group("name").strip(), match.group("email").strip()) + for match in COAUTHOR_RE.finditer(commit.body) + ] + + if check_authors: + if is_bot_identity(commit.author_name, commit.author_email): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> is a " + "bot/tool identity. Human harvested work should preserve the contributor " + "as author or use a human co-author trailer." + ) + elif ( + (expected := lookup_identity(aliases, commit.author_email, commit.author_name)) + and norm_key(commit.author_email) != norm_key(expected.email) + ): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> " + f"matches AUTHOR_MAP but is not canonical. Use author {expected.author()}." + ) + + for coauthor in coauthors: + if CANONICAL_NOREPLY_RE.match(coauthor.email): + continue + if is_bot_identity(coauthor.name, coauthor.email): + errors.append( + f"{prefix}: remove bot/tool co-author trailer " + f"{coauthor.name} <{coauthor.email}>; contributor trailers are for humans." + ) + continue + expected = lookup_identity(aliases, coauthor.email, coauthor.name) + if expected: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + f"GitHub-mappable. Use `{expected.trailer()}`." + ) + else: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + "numeric GitHub noreply and has no AUTHOR_MAP entry. Add an alias " + "or use `gh api users/ --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`." + ) + + coauthor_emails = {norm_key(coauthor.email) for coauthor in coauthors} + for login in HARVEST_RE.findall(commit.body): + expected = lookup_identity(aliases, login) + if expected is None: + errors.append( + f"{prefix}: harvested contributor @{login} is missing from .github/AUTHOR_MAP." + ) + continue + if ( + norm_key(commit.author_email) != norm_key(expected.email) + and norm_key(expected.email) not in coauthor_emails + ): + errors.append( + f"{prefix}: `Harvested from PR ... by @{login}` needs machine-readable " + f"credit. Add `{expected.trailer()}` or preserve the contributor as author." + ) + return errors + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--author-map", type=Path, default=DEFAULT_AUTHOR_MAP) + parser.add_argument("--range", default="origin/main..HEAD", help="git commit range to check") + parser.add_argument( + "--check-authors", + action="store_true", + help="also reject commit author emails that match known AUTHOR_MAP aliases", + ) + args = parser.parse_args(argv) + + try: + aliases = load_author_map(args.author_map) + commits = git_log(args.range) + errors = validate(commits, aliases, args.check_authors) + except Exception as exc: + print(f"co-author credit check failed to run: {exc}", file=sys.stderr) + return 2 + + if errors: + print("Co-author credit check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Co-author credit check passed for {len(commits)} commit(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))