11#!/usr/bin/env python3
22import json
33import os
4- import sys
5- import subprocess
64import re
7- from github import Github , Auth , GithubException # type: ignore
5+ import subprocess
6+ import sys
7+ from typing import TextIO
88
99# Constants for message titles
1010SUCCESS_TITLE = "# Commit-Check ✔️"
1111FAILURE_TITLE = "# Commit-Check ❌"
12+ COMMIT_MESSAGE_DELIMITER = "\x00 "
13+ COMMIT_SECTION_SEPARATOR = "\n ---\n "
1214
1315# Environment variables
1416MESSAGE = os .getenv ("MESSAGE" , "false" )
1921JOB_SUMMARY = os .getenv ("JOB_SUMMARY" , "false" )
2022PR_COMMENTS = os .getenv ("PR_COMMENTS" , "false" )
2123GITHUB_STEP_SUMMARY = os .environ ["GITHUB_STEP_SUMMARY" ]
22- GITHUB_TOKEN = os .getenv ("GITHUB_TOKEN" )
23- GITHUB_REPOSITORY = os .getenv ("GITHUB_REPOSITORY" )
24- GITHUB_REF = os .getenv ("GITHUB_REF" )
24+
25+
26+ def env_flag (name : str , default : str = "false" ) -> bool :
27+ """Read a GitHub Action boolean-style environment variable."""
28+ return os .getenv (name , default ).lower () == "true"
29+
30+
31+ MESSAGE_ENABLED = env_flag ("MESSAGE" )
32+ BRANCH_ENABLED = env_flag ("BRANCH" )
33+ AUTHOR_NAME_ENABLED = env_flag ("AUTHOR_NAME" )
34+ AUTHOR_EMAIL_ENABLED = env_flag ("AUTHOR_EMAIL" )
35+ DRY_RUN_ENABLED = env_flag ("DRY_RUN" )
36+ JOB_SUMMARY_ENABLED = env_flag ("JOB_SUMMARY" )
37+ PR_COMMENTS_ENABLED = env_flag ("PR_COMMENTS" )
2538
2639
2740def log_env_vars ():
@@ -35,35 +48,170 @@ def log_env_vars():
3548 print (f"PR_COMMENTS = { PR_COMMENTS } \n " )
3649
3750
38- def run_commit_check () -> int :
39- """Runs the commit-check command and logs the result."""
40- args = [
41- "--message" ,
42- "--branch" ,
43- "--author-name" ,
44- "--author-email" ,
51+ def is_pr_event () -> bool :
52+ """Return whether the workflow was triggered by a PR-style event."""
53+ return os .getenv ("GITHUB_EVENT_NAME" , "" ) in {"pull_request" , "pull_request_target" }
54+
55+
56+ def parse_commit_messages (output : str ) -> list [str ]:
57+ """Split git log output into individual commit messages."""
58+ return [
59+ message .strip ("\n " )
60+ for message in output .split (COMMIT_MESSAGE_DELIMITER )
61+ if message .strip ("\n " )
4562 ]
46- args = [
47- arg
48- for arg , value in zip (
49- args ,
50- [
51- MESSAGE ,
52- BRANCH ,
53- AUTHOR_NAME ,
54- AUTHOR_EMAIL ,
55- ],
63+
64+
65+ def get_messages_from_merge_ref () -> list [str ]:
66+ """Read PR commit messages from GitHub's synthetic merge commit."""
67+ result = subprocess .run (
68+ ["git" , "log" , "--pretty=format:%B%x00" , "--reverse" , "HEAD^1..HEAD^2" ],
69+ stdout = subprocess .PIPE ,
70+ stderr = subprocess .PIPE ,
71+ encoding = "utf-8" ,
72+ check = False ,
73+ )
74+ if result .returncode == 0 and result .stdout :
75+ return parse_commit_messages (result .stdout )
76+ return []
77+
78+
79+ def get_messages_from_head_ref (base_ref : str ) -> list [str ]:
80+ """Read PR commit messages when the workflow checks out the head SHA."""
81+ result = subprocess .run (
82+ [
83+ "git" ,
84+ "log" ,
85+ "--pretty=format:%B%x00" ,
86+ "--reverse" ,
87+ f"origin/{ base_ref } ..HEAD" ,
88+ ],
89+ stdout = subprocess .PIPE ,
90+ stderr = subprocess .PIPE ,
91+ encoding = "utf-8" ,
92+ check = False ,
93+ )
94+ if result .returncode == 0 and result .stdout :
95+ return parse_commit_messages (result .stdout )
96+ return []
97+
98+
99+ def get_pr_commit_messages () -> list [str ]:
100+ """Get all commit messages for the current PR workflow.
101+
102+ In pull_request-style workflows, actions/checkout checks out a synthetic merge
103+ commit (HEAD = merge of PR branch into base). HEAD^1 is the base branch
104+ tip, HEAD^2 is the PR branch tip. So HEAD^1..HEAD^2 gives all PR commits.
105+ If the workflow explicitly checks out the PR head SHA instead, fall back to
106+ diffing against origin/<base-ref> when that ref is available locally.
107+ """
108+ if not is_pr_event ():
109+ return []
110+
111+ try :
112+ messages = get_messages_from_merge_ref ()
113+ if messages :
114+ return messages
115+
116+ base_ref = os .getenv ("GITHUB_BASE_REF" , "" )
117+ if base_ref :
118+ return get_messages_from_head_ref (base_ref )
119+ except Exception as e :
120+ print (
121+ f"::warning::Failed to retrieve PR commit messages: { e } " ,
122+ file = sys .stderr ,
56123 )
57- if value == "true"
58- ]
124+ return []
59125
126+
127+ def run_check_command (
128+ args : list [str ],
129+ result_file : TextIO ,
130+ input_text : str | None = None ,
131+ output_prefix : str | None = None ,
132+ ) -> int :
133+ """Run commit-check and write both stdout and stderr to the result file."""
60134 command = ["commit-check" ] + args
61135 print (" " .join (command ))
62- with open ("result.txt" , "w" ) as result_file :
63- result = subprocess .run (
64- command , stdout = result_file , stderr = subprocess .PIPE , check = False
136+ result = subprocess .run (
137+ command ,
138+ input = input_text ,
139+ stdout = subprocess .PIPE ,
140+ stderr = subprocess .STDOUT ,
141+ text = True ,
142+ check = False ,
143+ )
144+ if result .stdout :
145+ if output_prefix :
146+ result_file .write (output_prefix )
147+ result_file .write (result .stdout .rstrip ("\n " ))
148+ result_file .write ("\n " )
149+ return result .returncode
150+
151+
152+ def run_pr_message_checks (pr_messages : list [str ], result_file : TextIO ) -> int :
153+ """Checks each PR commit message individually via commit-check --message.
154+
155+ Returns 1 if any message fails, 0 if all pass.
156+ """
157+ has_failure = False
158+ emitted_failure_output = False
159+ total = len (pr_messages )
160+ for index , msg in enumerate (pr_messages , start = 1 ):
161+ command_args = ["--message" ]
162+ if emitted_failure_output :
163+ command_args .append ("--no-banner" )
164+
165+ if emitted_failure_output :
166+ output_prefix = f"\n --- Commit { index } /{ total } :\n "
167+ else :
168+ output_prefix = None
169+
170+ return_code = run_check_command (
171+ command_args ,
172+ result_file ,
173+ input_text = msg ,
174+ output_prefix = output_prefix ,
65175 )
66- return result .returncode
176+ if return_code != 0 :
177+ has_failure = True
178+ emitted_failure_output = True
179+ return 1 if has_failure else 0
180+
181+
182+ def run_other_checks (args : list [str ], result_file : TextIO ) -> int :
183+ """Runs non-message checks (branch, author) once. Returns 0 if args is empty."""
184+ if not args :
185+ return 0
186+ return run_check_command (args , result_file )
187+
188+
189+ def build_check_args () -> list [str ]:
190+ """Map enabled validation switches to commit-check CLI arguments."""
191+ flags = [
192+ ("--message" , MESSAGE_ENABLED ),
193+ ("--branch" , BRANCH_ENABLED ),
194+ ("--author-name" , AUTHOR_NAME_ENABLED ),
195+ ("--author-email" , AUTHOR_EMAIL_ENABLED ),
196+ ]
197+ return [flag for flag , enabled in flags if enabled ]
198+
199+
200+ def run_commit_check () -> int :
201+ """Runs the commit-check command and logs the result."""
202+ args = build_check_args ()
203+ with open ("result.txt" , "w" ) as result_file :
204+ if MESSAGE_ENABLED :
205+ pr_messages = get_pr_commit_messages ()
206+ if pr_messages :
207+ # In PR context: check each commit message individually to avoid
208+ # only validating the synthetic merge commit at HEAD.
209+ message_rc = run_pr_message_checks (pr_messages , result_file )
210+ other_args = [a for a in args if a != "--message" ]
211+ other_rc = run_other_checks (other_args , result_file )
212+ return 1 if message_rc or other_rc else 0
213+ # Non-PR context or message disabled: run all checks at once
214+ return 1 if run_check_command (args , result_file ) else 0
67215
68216
69217def read_result_file () -> str | None :
@@ -77,21 +225,22 @@ def read_result_file() -> str | None:
77225 return None
78226
79227
228+ def build_result_body (result_text : str | None ) -> str :
229+ """Create the human-readable result body used in summaries and PR comments."""
230+ if result_text is None :
231+ return SUCCESS_TITLE
232+ return f"{ FAILURE_TITLE } \n ```\n { result_text } \n ```"
233+
234+
80235def add_job_summary () -> int :
81236 """Adds the commit check result to the GitHub job summary."""
82- if JOB_SUMMARY == "false" :
237+ if not JOB_SUMMARY_ENABLED :
83238 return 0
84239
85240 result_text = read_result_file ()
86241
87- summary_content = (
88- SUCCESS_TITLE
89- if result_text is None
90- else f"{ FAILURE_TITLE } \n ```\n { result_text } \n ```"
91- )
92-
93242 with open (GITHUB_STEP_SUMMARY , "a" ) as summary_file :
94- summary_file .write (summary_content )
243+ summary_file .write (build_result_body ( result_text ) )
95244
96245 return 0 if result_text is None else 1
97246
@@ -116,7 +265,7 @@ def is_fork_pr() -> bool:
116265
117266def add_pr_comments () -> int :
118267 """Posts the commit check result as a comment on the pull request."""
119- if PR_COMMENTS == "false" :
268+ if not PR_COMMENTS_ENABLED :
120269 return 0
121270
122271 # Fork PRs triggered by the pull_request event receive a read-only token;
@@ -132,6 +281,8 @@ def add_pr_comments() -> int:
132281 return 0
133282
134283 try :
284+ from github import Auth , Github , GithubException # type: ignore
285+
135286 token = os .getenv ("GITHUB_TOKEN" )
136287 repo_name = os .getenv ("GITHUB_REPOSITORY" )
137288 pr_number = os .getenv ("GITHUB_REF" )
@@ -147,15 +298,9 @@ def add_pr_comments() -> int:
147298 repo = g .get_repo (repo_name )
148299 pull_request = repo .get_issue (int (pr_number ))
149300
150- # Prepare comment content
151301 result_text = read_result_file ()
152- pr_comment_body = (
153- SUCCESS_TITLE
154- if result_text is None
155- else f"{ FAILURE_TITLE } \n ```\n { result_text } \n ```"
156- )
302+ pr_comment_body = build_result_body (result_text )
157303
158- # Fetch all existing comments on the PR
159304 comments = pull_request .get_comments ()
160305 matching_comments = [
161306 c
@@ -215,12 +360,9 @@ def main():
215360 """Main function to run commit-check, add job summary and post PR comments."""
216361 log_env_vars ()
217362
218- # Combine return codes
219- ret_code = run_commit_check ()
220- ret_code += add_job_summary ()
221- ret_code += add_pr_comments ()
363+ ret_code = max (run_commit_check (), add_job_summary (), add_pr_comments ())
222364
223- if DRY_RUN == "true" :
365+ if DRY_RUN_ENABLED :
224366 ret_code = 0
225367
226368 result_text = read_result_file ()
0 commit comments