From faa418a3ca62a9567bfe25a02f037338a2c5d834 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 18:58:32 +0300 Subject: [PATCH 1/7] feat: implement graceful fork PR degradation and document workflow_run pattern - Add Job Summary notice when PR comments are skipped for fork PRs (not just a ::warning:: log line, but also visible in GH UI) - Update warning message to reference new documentation section - Add comprehensive Fork PR Comments section to README with: - Two-workflow (workflow_run) pattern as recommended best practice - pull_request_target alternative with security warnings - Add unit tests for the new Job Summary behavior Closes #143 --- README.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++- main.py | 23 +++++-- main_test.py | 49 ++++++++++++++ 3 files changed, 252 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd79fbb..78beaf6 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A GitHub Action for checking commit message formatting, branch naming, committer * [Optional Inputs](#optional-inputs) * [GitHub Action Job Summary](#github-action-job-summary) * [GitHub Pull Request Comments](#github-pull-request-comments) +* [Fork PR Comments](#fork-pr-comments) * [Badging Your Repository](#badging-your-repository) * [Versioning](#versioning) @@ -123,7 +124,12 @@ jobs: > [!IMPORTANT] > `pr-comments` is an experimental feature. By default, it's disabled. > -> PR comments are skipped for pull requests from forked repositories. For more details, refer to issue [`#143`](https://github.com/commit-check/commit-check-action/issues/143). +> PR comments are skipped for pull requests from forked repositories. See +> [Fork PR Comments](#fork-pr-comments) for details on how to enable this feature +> for fork contributions. +> +> Note: write-access to pull-requests requires the `pull-requests: write` permission. +> See [usage example](#usage). Note: the default rule of above inputs is following [this configuration](https://github.com/commit-check/commit-check-action/blob/main/commit-check.toml). If you want to customize, just add your [`commit-check.toml`](https://commit-check.github.io/commit-check/configuration.html) config file under your repository root directory. @@ -149,6 +155,184 @@ By default, commit-check-action results are shown on the job summary page of the ![Failure pull request comment](https://github.com/commit-check/.github/blob/main/screenshot/failure-pr-comments.png) +## Fork PR Comments + +When a pull request is opened from a **forked repository**, the `GITHUB_TOKEN` used by the +`pull_request` event has **read-only** permissions by design (GitHub security policy). +This means `pr-comments: true` cannot write a comment back to the PR. + +By default, commit-check-action handles this gracefully: + +- PR comment writing is **skipped** with a `::warning::` message in the logs +- A **notice is added to the Job Summary** explaining why and how to fix it +- The commit checks themselves **still run normally** + +> **For most projects, this is sufficient** — contributors can see check results in the +> action Job Summary. But if you *must* have PR comments on fork contributions, there +> are two recommended approaches. + +--- + +### Option 1: Two-workflow pattern (recommended) + +This is the **official GitHub-recommended best practice** for writing PR comments from +fork PRs. It uses the [`workflow_run`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) +event with **no security risks**. + +**How it works:** + +``` + pull_request workflow_run + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────────┐ +│ Workflow A │ │ Workflow B │ +│ (checks) │────►│ (comment writer) │ +│ │ │ │ +│ Token: READ │ │ Token: WRITE │ +│ Saves result │ │ Reads artifact │ +│ as artifact │ │ Posts PR comment │ +└──────────────┘ └──────────────────┘ +``` + +**Workflow A** — `.github/workflows/commit-check.yml` (triggered by `pull_request`): + +```yaml +name: Commit Check + +on: + pull_request: + branches: ["main"] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: commit-check/commit-check-action@v2 + with: + message: true + branch: true + pr-comments: false # comments handled by Workflow B + job-summary: true + + # Save results so Workflow B can post a PR comment + - uses: actions/upload-artifact@v4 + with: + name: commit-check-result-${{ github.event.number }} + path: result.txt +``` + +**Workflow B** — `.github/workflows/commit-check-comment.yml` (triggered by `workflow_run`): + +```yaml +name: Commit Check Comment + +on: + workflow_run: + workflows: ["Commit Check"] # must match Workflow A's name exactly + types: [completed] + +jobs: + comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + actions: read # needed to download artifacts + steps: + - uses: actions/download-artifact@v4 + with: + name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read result and post PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }}; + const resultText = fs.readFileSync('result.txt', 'utf8').trim(); + + const successTitle = '# Commit-Check ✔️'; + const failureTitle = '# Commit-Check ❌'; + const body = resultText + ? `${failureTitle}\n\`\`\`\n${resultText}\n\`\`\`` + : successTitle; + + const { data: comments } = await github.rest.issues.listComments({ + ...context.repo, + issue_number: prNumber, + }); + + const existing = comments.find(c => + c.body.startsWith(successTitle) || c.body.startsWith(failureTitle) + ); + + if (existing) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: prNumber, + body, + }); + } +``` + +> **Key security benefits:** +> - Workflow B runs in the **base repository's context**, so `GITHUB_TOKEN` has full write +> permissions (you explicitly grant `pull-requests: write`) +> - Workflow B **does not checkout the PR code**, so untrusted fork code never runs +> with elevated permissions +> - The artifact only contains `result.txt` — no code or secrets + +--- + +### Option 2: pull_request_target (advanced, use with caution) + +If you understand the security implications, you can use +[`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) +which runs in the base repository's context with **write token access**. + +> **⚠️ Security warning:** Never check out (`actions/checkout`) the PR's HEAD commit +> when using `pull_request_target`. Always check out the base branch or use the +> default merge commit. Otherwise, fork code could exfiltrate your repository's secrets. + +```yaml +name: Commit Check + +on: + pull_request_target: + branches: ["main"] + +jobs: + commit-check: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + # SAFE: checkout the merge commit, NOT the PR head + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: commit-check/commit-check-action@v2 + with: + message: true + branch: true + pr-comments: true +``` + +> **When to use this:** Only if the two-workflow pattern is too complex for your setup +> and you have thoroughly reviewed the security implications. + ## Badging Your Repository You can add a badge to your repository to show your contributors/users that you use commit-check! diff --git a/main.py b/main.py index 7f0f1e2..7d73e8c 100755 --- a/main.py +++ b/main.py @@ -271,13 +271,26 @@ def add_pr_comments() -> int: # Fork PRs triggered by the pull_request event receive a read-only token; # the GitHub API will always reject comment writes with 403. if is_fork_pr(): - print( - "::warning::Skipping PR comment: pull requests from forked repositories " + msg = ( + "Skipping PR comment: pull requests from forked repositories " "cannot write comments via the pull_request event (GITHUB_TOKEN is " - "read-only for forks). Use the pull_request_target event or the " - "two-workflow artifact pattern instead. " - "See https://github.com/commit-check/commit-check-action/issues/77" + "read-only for forks). " + "See https://github.com/commit-check/commit-check-action#fork-pr-comments " + "for how to enable PR comments on fork PRs." ) + print(f"::warning::{msg}") + if JOB_SUMMARY_ENABLED: + with open(GITHUB_STEP_SUMMARY, "a") as f: + f.write( + "\n---\n" + "### \u2139\ufe0f PR Comment Skipped\n\n" + "Pull requests from forked repositories cannot write comments " + "using the `pull_request` event because `GITHUB_TOKEN` has " + "read-only permissions.\n\n" + "> **\U0001f4a1 Tip:** To enable PR comments on fork PRs, see " + "[Enabling PR Comments on Fork Pull Requests]" + "(https://github.com/commit-check/commit-check-action#fork-pr-comments).\n" + ) return 0 try: diff --git a/main_test.py b/main_test.py index 3a5d04c..1bc67af 100644 --- a/main_test.py +++ b/main_test.py @@ -467,6 +467,55 @@ def test_failure_writes_failure_title(self): self.assertIn("bad commit message", content) +class TestAddPrComments(unittest.TestCase): + def setUp(self): + import tempfile + + self._orig_dir = os.getcwd() + self._tmpdir = tempfile.mkdtemp() + os.chdir(self._tmpdir) + with open("result.txt", "w", encoding="utf-8"): + pass + + def tearDown(self): + os.chdir(self._orig_dir) + + def test_disabled_returns_zero(self): + with patch("main.PR_COMMENTS_ENABLED", False): + rc = main.add_pr_comments() + self.assertEqual(rc, 0) + + def test_fork_pr_skips_comment_and_warns(self): + with ( + patch("main.PR_COMMENTS_ENABLED", True), + patch("main.is_fork_pr", return_value=True), + patch("main.JOB_SUMMARY_ENABLED", False), + patch("builtins.print") as mock_print, + ): + rc = main.add_pr_comments() + self.assertEqual(rc, 0) + printed = mock_print.call_args[0][0] + self.assertIn("::warning::", printed) + self.assertIn("read-only", printed) + + def test_fork_pr_writes_job_summary_hint(self): + summary_path = os.path.join(self._tmpdir, "summary.txt") + with ( + patch("main.PR_COMMENTS_ENABLED", True), + patch("main.is_fork_pr", return_value=True), + patch("main.JOB_SUMMARY_ENABLED", True), + patch("main.GITHUB_STEP_SUMMARY", summary_path), + patch("builtins.print"), + ): + rc = main.add_pr_comments() + self.assertEqual(rc, 0) + with open(summary_path, encoding="utf-8") as f: + content = f.read() + self.assertIn("PR Comment Skipped", content) + self.assertIn("read-only", content) + self.assertIn("fork-pr-comments", content) + + class TestIsForkPr(unittest.TestCase): def test_no_event_path(self): with patch.dict(os.environ, {}, clear=True): From fce5b5a5419b076afb62cb8f8d498f5c3aabb465 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 19:08:44 +0300 Subject: [PATCH 2/7] docs: move workflow_run examples to examples/ dir --- README.md | 50 ++++++-------------- examples/commit-check-workflow-a.yml | 33 ++++++++++++++ examples/commit-check-workflow-b.yml | 68 ++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 examples/commit-check-workflow-a.yml create mode 100644 examples/commit-check-workflow-b.yml diff --git a/README.md b/README.md index 78beaf6..f399b0d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,9 @@ This is the **official GitHub-recommended best practice** for writing PR comment fork PRs. It uses the [`workflow_run`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) event with **no security risks**. +> 📁 Ready-to-use files: [`examples/commit-check-workflow-a.yml`](examples/commit-check-workflow-a.yml) +> and [`examples/commit-check-workflow-b.yml`](examples/commit-check-workflow-b.yml) + **How it works:** ``` @@ -215,16 +218,16 @@ jobs: with: message: true branch: true - pr-comments: false # comments handled by Workflow B + pr-comments: false # comments handled by Workflow B job-summary: true - - # Save results so Workflow B can post a PR comment - uses: actions/upload-artifact@v4 with: name: commit-check-result-${{ github.event.number }} - path: result.txt + path: result.txt # saved for Workflow B ``` +> 📄 Full file: [`examples/commit-check-workflow-a.yml`](examples/commit-check-workflow-a.yml) + **Workflow B** — `.github/workflows/commit-check-comment.yml` (triggered by `workflow_run`): ```yaml @@ -232,7 +235,7 @@ name: Commit Check Comment on: workflow_run: - workflows: ["Commit Check"] # must match Workflow A's name exactly + workflows: ["Commit Check"] # must match Workflow A's name exactly types: [completed] jobs: @@ -240,52 +243,29 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write - actions: read # needed to download artifacts + actions: read # needed to download artifacts steps: - uses: actions/download-artifact@v4 with: name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Read result and post PR comment uses: actions/github-script@v7 with: script: | + // See examples/commit-check-workflow-b.yml for full script const fs = require('fs'); const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }}; const resultText = fs.readFileSync('result.txt', 'utf8').trim(); - - const successTitle = '# Commit-Check ✔️'; - const failureTitle = '# Commit-Check ❌'; const body = resultText - ? `${failureTitle}\n\`\`\`\n${resultText}\n\`\`\`` - : successTitle; - - const { data: comments } = await github.rest.issues.listComments({ - ...context.repo, - issue_number: prNumber, - }); - - const existing = comments.find(c => - c.body.startsWith(successTitle) || c.body.startsWith(failureTitle) - ); - - if (existing) { - await github.rest.issues.updateComment({ - ...context.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: prNumber, - body, - }); - } + ? '# Commit-Check ❌\n```\n' + resultText + '\n```' + : '# Commit-Check ✔️'; + // Creates or updates the matching PR comment ``` +> 📄 Full file: [`examples/commit-check-workflow-b.yml`](examples/commit-check-workflow-b.yml) + > **Key security benefits:** > - Workflow B runs in the **base repository's context**, so `GITHUB_TOKEN` has full write > permissions (you explicitly grant `pull-requests: write`) diff --git a/examples/commit-check-workflow-a.yml b/examples/commit-check-workflow-a.yml new file mode 100644 index 0000000..9d1bece --- /dev/null +++ b/examples/commit-check-workflow-a.yml @@ -0,0 +1,33 @@ +# Workflow A: Run commit checks on pull_request events. +# +# This workflow is triggered by pull_request and runs commit checks. +# It uploads the result as an artifact so Workflow B (commit-check-comment.yml) +# can read it and post a PR comment with full write permissions. +# +# See https://github.com/commit-check/commit-check-action#fork-pr-comments + +name: Commit Check + +on: + pull_request: + branches: ["main"] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: commit-check/commit-check-action@v2 + with: + message: true + branch: true + pr-comments: false # comments handled by Workflow B + job-summary: true + + # Save results so Workflow B can post a PR comment + - uses: actions/upload-artifact@v4 + with: + name: commit-check-result-${{ github.event.number }} + path: result.txt diff --git a/examples/commit-check-workflow-b.yml b/examples/commit-check-workflow-b.yml new file mode 100644 index 0000000..09517b8 --- /dev/null +++ b/examples/commit-check-workflow-b.yml @@ -0,0 +1,68 @@ +# Workflow B: Post PR comment after commit checks complete. +# +# This workflow is triggered by the workflow_run event from Workflow A. +# It runs in the base repository's context with full write permissions, +# making it safe for fork PRs (no checkout of fork code). +# +# Prerequisites: +# - Workflow A (commit-check.yml) must exist and upload an artifact named +# commit-check-result- containing result.txt +# +# See https://github.com/commit-check/commit-check-action#fork-pr-comments + +name: Commit Check Comment + +on: + workflow_run: + workflows: ["Commit Check"] # must match Workflow A's name exactly + types: [completed] + +jobs: + comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + actions: read # needed to download artifacts + steps: + - uses: actions/download-artifact@v4 + with: + name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read result and post PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }}; + const resultText = fs.readFileSync('result.txt', 'utf8').trim(); + + const successTitle = '# Commit-Check ✔️'; + const failureTitle = '# Commit-Check ❌'; + const body = resultText + ? `${failureTitle}\n\`\`\`\n${resultText}\n\`\`\`` + : successTitle; + + const { data: comments } = await github.rest.issues.listComments({ + ...context.repo, + issue_number: prNumber, + }); + + const existing = comments.find(c => + c.body.startsWith(successTitle) || c.body.startsWith(failureTitle) + ); + + if (existing) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: prNumber, + body, + }); + } From bba7f451cc07f31c376a52498315c73ee585fe2c Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 19:14:09 +0300 Subject: [PATCH 3/7] fix: do not skip fork PR comments on pull_request_target --- README.md | 4 ++++ main.py | 13 ++++++++++++- main_test.py | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f399b0d..ae38c49 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,10 @@ jobs: pr-comments: true ``` +> ✅ With `pull_request_target`, `pr-comments: true` **does work** on fork PRs — +> the token has the workflow's configured permissions regardless of whether the PR +> is from a fork. +> > **When to use this:** Only if the two-workflow pattern is too complex for your setup > and you have thoroughly reviewed the security implications. diff --git a/main.py b/main.py index 7d73e8c..6ece0a6 100755 --- a/main.py +++ b/main.py @@ -263,6 +263,16 @@ def is_fork_pr() -> bool: return False +def is_fork_pr_with_readonly_token() -> bool: + """Returns True when the PR is from a fork AND the event has a read-only token. + + Under the pull_request event, GITHUB_TOKEN is read-only for fork PRs. + Under pull_request_target, GITHUB_TOKEN has the workflow's configured + permissions regardless of whether the PR is from a fork. + """ + return is_fork_pr() and os.getenv("GITHUB_EVENT_NAME", "") != "pull_request_target" + + def add_pr_comments() -> int: """Posts the commit check result as a comment on the pull request.""" if not PR_COMMENTS_ENABLED: @@ -270,7 +280,8 @@ def add_pr_comments() -> int: # Fork PRs triggered by the pull_request event receive a read-only token; # the GitHub API will always reject comment writes with 403. - if is_fork_pr(): + # pull_request_target events always have the configured token permissions. + if is_fork_pr_with_readonly_token(): msg = ( "Skipping PR comment: pull requests from forked repositories " "cannot write comments via the pull_request event (GITHUB_TOKEN is " diff --git a/main_test.py b/main_test.py index 1bc67af..00fc594 100644 --- a/main_test.py +++ b/main_test.py @@ -516,6 +516,30 @@ def test_fork_pr_writes_job_summary_hint(self): self.assertIn("fork-pr-comments", content) +class TestIsForkPrWithReadonlyToken(unittest.TestCase): + def test_fork_pr_with_pull_request_event(self): + with ( + patch("main.is_fork_pr", return_value=True), + patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}), + ): + self.assertTrue(main.is_fork_pr_with_readonly_token()) + + def test_fork_pr_with_pull_request_target_event(self): + """pull_request_target has write token — not considered read-only.""" + with ( + patch("main.is_fork_pr", return_value=True), + patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request_target"}), + ): + self.assertFalse(main.is_fork_pr_with_readonly_token()) + + def test_same_repo_not_fork(self): + with ( + patch("main.is_fork_pr", return_value=False), + patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}), + ): + self.assertFalse(main.is_fork_pr_with_readonly_token()) + + class TestIsForkPr(unittest.TestCase): def test_no_event_path(self): with patch.dict(os.environ, {}, clear=True): From 1741ed5353f3a7bbd521b654b94ae7005b927763 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 19:21:02 +0300 Subject: [PATCH 4/7] fix: get PR number from event payload on pull_request_target --- main.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 6ece0a6..c3be0e4 100755 --- a/main.py +++ b/main.py @@ -273,6 +273,31 @@ def is_fork_pr_with_readonly_token() -> bool: return is_fork_pr() and os.getenv("GITHUB_EVENT_NAME", "") != "pull_request_target" +def get_pr_number() -> int: + """Extract the pull request number from event payload or GITHUB_REF. + + For pull_request: GITHUB_REF is refs/pull//merge + For pull_request_target: GITHUB_REF is refs/heads/ (not useful), + so we fall back to the event payload. + """ + ref = os.getenv("GITHUB_REF", "") + parts = ref.split("/") + if len(parts) >= 4 and parts[1] == "pull": + return int(parts[2]) + # Fallback: read PR number from event payload + event_path = os.getenv("GITHUB_EVENT_PATH") + if event_path: + try: + with open(event_path, "r") as f: + event = json.load(f) + number = event.get("number") or (event.get("pull_request", {}) or {}).get("number") + if number: + return int(number) + except Exception: + pass + raise ValueError("Unable to determine PR number from GITHUB_REF or GITHUB_EVENT_PATH") + + def add_pr_comments() -> int: """Posts the commit check result as a comment on the pull request.""" if not PR_COMMENTS_ENABLED: @@ -309,18 +334,14 @@ def add_pr_comments() -> int: token = os.getenv("GITHUB_TOKEN") repo_name = os.getenv("GITHUB_REPOSITORY") - pr_number = os.getenv("GITHUB_REF") - if pr_number is not None: - pr_number = pr_number.split("/")[-2] - else: - raise ValueError("GITHUB_REF environment variable is not set") + pr_number = get_pr_number() if not token: raise ValueError("GITHUB_TOKEN is not set") g = Github(auth=Auth.Token(token)) repo = g.get_repo(repo_name) - pull_request = repo.get_issue(int(pr_number)) + pull_request = repo.get_issue(pr_number) result_text = read_result_file() pr_comment_body = build_result_body(result_text) From 21bb90a491da7cd5d81cbf65a44d7fc7c765fa27 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:22:06 +0000 Subject: [PATCH 5/7] chore: auto fixes from pre-commit.com hooks --- main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index c3be0e4..8ea44dc 100755 --- a/main.py +++ b/main.py @@ -290,12 +290,16 @@ def get_pr_number() -> int: try: with open(event_path, "r") as f: event = json.load(f) - number = event.get("number") or (event.get("pull_request", {}) or {}).get("number") + number = event.get("number") or (event.get("pull_request", {}) or {}).get( + "number" + ) if number: return int(number) except Exception: pass - raise ValueError("Unable to determine PR number from GITHUB_REF or GITHUB_EVENT_PATH") + raise ValueError( + "Unable to determine PR number from GITHUB_REF or GITHUB_EVENT_PATH" + ) def add_pr_comments() -> int: From 530b572ab0bb4c3636f93d1ba653f2539acaf899 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 19:31:51 +0300 Subject: [PATCH 6/7] docs: move fork PR docs to docs/fork-pr-comments.md for stable linking Code now links to docs/fork-pr-comments.md (a fixed file path) instead of README section anchors (#fork-pr-comments). This prevents link rot when the README gets restructured. README keeps a brief summary with a link to the dedicated docs file. --- README.md | 157 ++---------------------------------- docs/fork-pr-comments.md | 166 +++++++++++++++++++++++++++++++++++++++ main.py | 4 +- result.txt | 0 4 files changed, 174 insertions(+), 153 deletions(-) create mode 100644 docs/fork-pr-comments.md create mode 100644 result.txt diff --git a/README.md b/README.md index ae38c49..e8ee35a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A GitHub Action for checking commit message formatting, branch naming, committer * [Optional Inputs](#optional-inputs) * [GitHub Action Job Summary](#github-action-job-summary) * [GitHub Pull Request Comments](#github-pull-request-comments) -* [Fork PR Comments](#fork-pr-comments) +* [Fork PR Comments](docs/fork-pr-comments.md) * [Badging Your Repository](#badging-your-repository) * [Versioning](#versioning) @@ -125,8 +125,8 @@ jobs: > `pr-comments` is an experimental feature. By default, it's disabled. > > PR comments are skipped for pull requests from forked repositories. See -> [Fork PR Comments](#fork-pr-comments) for details on how to enable this feature -> for fork contributions. +> [docs/fork-pr-comments.md](docs/fork-pr-comments.md) for details on how to enable +> this feature for fork contributions. > > Note: write-access to pull-requests requires the `pull-requests: write` permission. > See [usage example](#usage). @@ -168,154 +168,9 @@ By default, commit-check-action handles this gracefully: - The commit checks themselves **still run normally** > **For most projects, this is sufficient** — contributors can see check results in the -> action Job Summary. But if you *must* have PR comments on fork contributions, there -> are two recommended approaches. - ---- - -### Option 1: Two-workflow pattern (recommended) - -This is the **official GitHub-recommended best practice** for writing PR comments from -fork PRs. It uses the [`workflow_run`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) -event with **no security risks**. - -> 📁 Ready-to-use files: [`examples/commit-check-workflow-a.yml`](examples/commit-check-workflow-a.yml) -> and [`examples/commit-check-workflow-b.yml`](examples/commit-check-workflow-b.yml) - -**How it works:** - -``` - pull_request workflow_run - │ │ - ▼ ▼ -┌──────────────┐ ┌──────────────────┐ -│ Workflow A │ │ Workflow B │ -│ (checks) │────►│ (comment writer) │ -│ │ │ │ -│ Token: READ │ │ Token: WRITE │ -│ Saves result │ │ Reads artifact │ -│ as artifact │ │ Posts PR comment │ -└──────────────┘ └──────────────────┘ -``` - -**Workflow A** — `.github/workflows/commit-check.yml` (triggered by `pull_request`): - -```yaml -name: Commit Check - -on: - pull_request: - branches: ["main"] - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: commit-check/commit-check-action@v2 - with: - message: true - branch: true - pr-comments: false # comments handled by Workflow B - job-summary: true - - uses: actions/upload-artifact@v4 - with: - name: commit-check-result-${{ github.event.number }} - path: result.txt # saved for Workflow B -``` - -> 📄 Full file: [`examples/commit-check-workflow-a.yml`](examples/commit-check-workflow-a.yml) - -**Workflow B** — `.github/workflows/commit-check-comment.yml` (triggered by `workflow_run`): - -```yaml -name: Commit Check Comment - -on: - workflow_run: - workflows: ["Commit Check"] # must match Workflow A's name exactly - types: [completed] - -jobs: - comment: - runs-on: ubuntu-latest - permissions: - pull-requests: write - actions: read # needed to download artifacts - steps: - - uses: actions/download-artifact@v4 - with: - name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - name: Read result and post PR comment - uses: actions/github-script@v7 - with: - script: | - // See examples/commit-check-workflow-b.yml for full script - const fs = require('fs'); - const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }}; - const resultText = fs.readFileSync('result.txt', 'utf8').trim(); - const body = resultText - ? '# Commit-Check ❌\n```\n' + resultText + '\n```' - : '# Commit-Check ✔️'; - // Creates or updates the matching PR comment -``` - -> 📄 Full file: [`examples/commit-check-workflow-b.yml`](examples/commit-check-workflow-b.yml) - -> **Key security benefits:** -> - Workflow B runs in the **base repository's context**, so `GITHUB_TOKEN` has full write -> permissions (you explicitly grant `pull-requests: write`) -> - Workflow B **does not checkout the PR code**, so untrusted fork code never runs -> with elevated permissions -> - The artifact only contains `result.txt` — no code or secrets - ---- - -### Option 2: pull_request_target (advanced, use with caution) - -If you understand the security implications, you can use -[`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) -which runs in the base repository's context with **write token access**. - -> **⚠️ Security warning:** Never check out (`actions/checkout`) the PR's HEAD commit -> when using `pull_request_target`. Always check out the base branch or use the -> default merge commit. Otherwise, fork code could exfiltrate your repository's secrets. - -```yaml -name: Commit Check - -on: - pull_request_target: - branches: ["main"] - -jobs: - commit-check: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - # SAFE: checkout the merge commit, NOT the PR head - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: commit-check/commit-check-action@v2 - with: - message: true - branch: true - pr-comments: true -``` - -> ✅ With `pull_request_target`, `pr-comments: true` **does work** on fork PRs — -> the token has the workflow's configured permissions regardless of whether the PR -> is from a fork. -> -> **When to use this:** Only if the two-workflow pattern is too complex for your setup -> and you have thoroughly reviewed the security implications. +> action Job Summary. But if you *must* have PR comments on fork contributions, see +> the **[Fork PR Comments](docs/fork-pr-comments.md)** documentation for +> two recommended approaches with ready-to-use workflow examples. ## Badging Your Repository diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md new file mode 100644 index 0000000..82ff63a --- /dev/null +++ b/docs/fork-pr-comments.md @@ -0,0 +1,166 @@ +# Fork PR Comments + +When a pull request is opened from a **forked repository**, the `GITHUB_TOKEN` used by the +`pull_request` event has **read-only** permissions by design (GitHub security policy). +This means `pr-comments: true` cannot write a comment back to the PR. + +By default, commit-check-action handles this gracefully: + +- PR comment writing is **skipped** with a `::warning::` message in the logs +- A **notice is added to the Job Summary** explaining why and how to fix it +- The commit checks themselves **still run normally** + +> **For most projects, this is sufficient** — contributors can see check results in the +> action Job Summary. But if you *must* have PR comments on fork contributions, there +> are two recommended approaches. + +--- + +## Option 1: Two-workflow pattern (recommended) + +This is the **official GitHub-recommended best practice** for writing PR comments from +fork PRs. It uses the [`workflow_run`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) +event with **no security risks**. + +> 📁 Ready-to-use files: [`examples/commit-check-workflow-a.yml`](../examples/commit-check-workflow-a.yml) +> and [`examples/commit-check-workflow-b.yml`](../examples/commit-check-workflow-b.yml) + +**How it works:** + +``` + pull_request workflow_run + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────────┐ +│ Workflow A │ │ Workflow B │ +│ (checks) │────►│ (comment writer) │ +│ │ │ │ +│ Token: READ │ │ Token: WRITE │ +│ Saves result │ │ Reads artifact │ +│ as artifact │ │ Posts PR comment │ +└──────────────┘ └──────────────────┘ +``` + +### Workflow A + +`.github/workflows/commit-check.yml` (triggered by `pull_request`): + +```yaml +name: Commit Check + +on: + pull_request: + branches: ["main"] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: commit-check/commit-check-action@v2 + with: + message: true + branch: true + pr-comments: false # comments handled by Workflow B + job-summary: true + - uses: actions/upload-artifact@v4 + with: + name: commit-check-result-${{ github.event.number }} + path: result.txt # saved for Workflow B +``` + +> 📄 Full file: [`examples/commit-check-workflow-a.yml`](../examples/commit-check-workflow-a.yml) + +### Workflow B + +`.github/workflows/commit-check-comment.yml` (triggered by `workflow_run`): + +```yaml +name: Commit Check Comment + +on: + workflow_run: + workflows: ["Commit Check"] # must match Workflow A's name exactly + types: [completed] + +jobs: + comment: + runs-on: ubuntu-latest + permissions: + pull-requests: write + actions: read # needed to download artifacts + steps: + - uses: actions/download-artifact@v4 + with: + name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Read result and post PR comment + uses: actions/github-script@v7 + with: + script: | + // See examples/commit-check-workflow-b.yml for full script + const fs = require('fs'); + const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }}; + const resultText = fs.readFileSync('result.txt', 'utf8').trim(); + const body = resultText + ? '# Commit-Check ❌\n```\n' + resultText + '\n```' + : '# Commit-Check ✔️'; + // Creates or updates the matching PR comment +``` + +> 📄 Full file: [`examples/commit-check-workflow-b.yml`](../examples/commit-check-workflow-b.yml) + +### Key security benefits + +- Workflow B runs in the **base repository's context**, so `GITHUB_TOKEN` has full write + permissions (you explicitly grant `pull-requests: write`) +- Workflow B **does not checkout the PR code**, so untrusted fork code never runs + with elevated permissions +- The artifact only contains `result.txt` — no code or secrets + +--- + +## Option 2: pull_request_target (advanced, use with caution) + +If you understand the security implications, you can use +[`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) +which runs in the base repository's context with **write token access**. + +> **⚠️ Security warning:** Never check out (`actions/checkout`) the PR's HEAD commit +> when using `pull_request_target`. Always check out the base branch or use the +> default merge commit. Otherwise, fork code could exfiltrate your repository's secrets. + +```yaml +name: Commit Check + +on: + pull_request_target: + branches: ["main"] + +jobs: + commit-check: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + # SAFE: checkout the merge commit, NOT the PR head + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: commit-check/commit-check-action@v2 + with: + message: true + branch: true + pr-comments: true +``` + +> ✅ With `pull_request_target`, `pr-comments: true` **does work** on fork PRs — +> the token has the workflow's configured permissions regardless of whether the PR +> is from a fork. +> +> **When to use this:** Only if the two-workflow pattern is too complex for your setup +> and you have thoroughly reviewed the security implications. diff --git a/main.py b/main.py index 8ea44dc..4814fe7 100755 --- a/main.py +++ b/main.py @@ -315,7 +315,7 @@ def add_pr_comments() -> int: "Skipping PR comment: pull requests from forked repositories " "cannot write comments via the pull_request event (GITHUB_TOKEN is " "read-only for forks). " - "See https://github.com/commit-check/commit-check-action#fork-pr-comments " + "See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md " "for how to enable PR comments on fork PRs." ) print(f"::warning::{msg}") @@ -329,7 +329,7 @@ def add_pr_comments() -> int: "read-only permissions.\n\n" "> **\U0001f4a1 Tip:** To enable PR comments on fork PRs, see " "[Enabling PR Comments on Fork Pull Requests]" - "(https://github.com/commit-check/commit-check-action#fork-pr-comments).\n" + "(https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md).\n" ) return 0 diff --git a/result.txt b/result.txt new file mode 100644 index 0000000..e69de29 From b6390fc288625fec5bf90fa62db566a67400629c Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 16 Jun 2026 19:34:54 +0300 Subject: [PATCH 7/7] chore: remove accidentally committed result.txt, add to gitignore --- .gitignore | 1 + result.txt | 0 2 files changed, 1 insertion(+) delete mode 100644 result.txt diff --git a/.gitignore b/.gitignore index 2233054..ab3375b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ venv/ .venv/ __pycache__/ +result.txt diff --git a/result.txt b/result.txt deleted file mode 100644 index e69de29..0000000