From ccfa72b9f214440713dffad662e371c5fd9b0c32 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:08:58 +0000 Subject: [PATCH 01/11] feat(output): expose the rendered report and fix the fork-PR comment pattern The two-workflow pattern in docs/fork-pr-comments.md and examples/ told Workflow A to upload `result.txt`, a file the action has never written, and did so without `if: always()`, so on the failing runs that need a comment the upload step never ran at all. Workflow B then re-rendered a comment under a title the action no longer recognises as its own, and read the PR number from `workflow_run.pull_requests[0]`, which is empty for fork PRs. Add a `report` output beside `result`: the Markdown report byte for byte, marker to footer, written to GITHUB_OUTPUT in the same heredoc form. A workflow_run job can post it verbatim, so the fork comment is identical to one the action posts itself and a later `pr-comments: true` run adopts it instead of adding a second one. Rewrite the doc and both examples around it: Workflow A saves report.md, result.json and pr-number under `if: always()`; Workflow B downloads by run id and updates the comment carrying ``. Document the output in the README and fix the diagram labels. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 22 ++++++++++ action.yml | 3 ++ docs/fork-pr-comments.md | 61 +++++++++++++++++++++------- examples/commit-check-workflow-a.yml | 35 ++++++++++++---- examples/commit-check-workflow-b.yml | 50 +++++++++++++---------- main.py | 29 +++++++++++-- main_test.py | 42 +++++++++++++++---- 7 files changed, 188 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 824bca5..8ee850b 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,28 @@ check outcomes (`rule_id`, `check`, `status`, `value`, `error`, `suggest`, `fix`, `docs_url`) exactly as produced by `commit-check --format json`, so downstream jobs can build their own reports or gate on individual rules. +### `report` + +The rendered Markdown report — byte for byte the text the +[job summary](#github-action-job-summary) and the +[PR comment](#github-pull-request-comments) show, opening with the +`` marker. It exists for workflows that have to post +the comment themselves: a `pull_request` run on a fork has a read-only token, so +it saves the report as an artifact and a `workflow_run` job posts it verbatim — +see [Fork PR Comments](docs/fork-pr-comments.md). Because the text is the +action's own, that comment is later found and edited in place like any other. + +```yaml +- name: Save the report + if: always() && steps.commit-check.outputs.report != '' + env: + REPORT: ${{ steps.commit-check.outputs.report }} + run: printf '%s\n' "$REPORT" > report.md +``` + +Treat `report` as text to display, not data to parse; `result` is the contract +for that. + ## GitHub Action Job Summary By default, commit-check-action results are shown on the job summary page of the diff --git a/action.yml b/action.yml index f0566de..d874207 100644 --- a/action.yml +++ b/action.yml @@ -44,6 +44,9 @@ outputs: # mapping (and the step id it refers to) the output is always the empty # string, and fromJSON('') fails the calling workflow. value: ${{ steps.commit-check.outputs.result }} + report: + description: The rendered Markdown report, byte for byte what the job summary and PR comment show. Save it as an artifact from a pull_request run and post it verbatim from a workflow_run job to comment on fork pull requests (see docs/fork-pr-comments.md). + value: ${{ steps.commit-check.outputs.report }} runs: using: "composite" diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md index b267011..3f95996 100644 --- a/docs/fork-pr-comments.md +++ b/docs/fork-pr-comments.md @@ -27,6 +27,16 @@ event with **no security risks**. **How it works:** +Workflow A runs the checks with `pr-comments: false` and saves the action's +[`report`](../README.md#report) output — the rendered Markdown the job summary shows — plus +the [`result`](../README.md#result) JSON and the PR number, as an artifact. Workflow B, +triggered by `workflow_run` in the base repository, downloads the artifact and posts (or +updates) one comment carrying the `` marker, using `report.md` +verbatim. The fork comment is therefore the same comment the action posts on a non-fork +PR, and a later run with `pr-comments: true` finds and edits it instead of adding a +second one. The artifact contains only `report.md`, `result.json` and `pr-number` — no +code or secrets. + ``` pull_request workflow_run │ │ @@ -36,7 +46,7 @@ event with **no security risks**. │ (checks) │────►│ (comment writer) │ │ │ │ │ │ Token: READ │ │ Token: WRITE │ -│ Saves result │ │ Reads artifact │ +│ Saves report │ │ Downloads it │ │ as artifact │ │ Posts PR comment │ └──────────────┘ └──────────────────┘ ``` @@ -55,20 +65,40 @@ on: jobs: check: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: commit-check/commit-check-action@v2 + id: commit-check with: message: true branch: true - pr-comments: false # comments handled by Workflow B + pr-comments: false # comments handled by Workflow B job-summary: true + + # The action exits 1 on a failure, so both steps below need `always()` + # or the artifact is missing on exactly the runs that need a comment. + # The outputs are written before the action exits, so they are present + # on failing runs too. + - name: Save the report for Workflow B + if: always() && steps.commit-check.outputs.report != '' + env: + REPORT: ${{ steps.commit-check.outputs.report }} + RESULT: ${{ steps.commit-check.outputs.result }} + PR_NUMBER: ${{ github.event.number }} + run: | + mkdir -p commit-check-result + printf '%s\n' "$REPORT" > commit-check-result/report.md + printf '%s\n' "$RESULT" > commit-check-result/result.json + printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - uses: actions/upload-artifact@v4 + if: always() && steps.commit-check.outputs.report != '' with: - name: commit-check-result-${{ github.event.number }} - path: result.txt # saved for Workflow B + name: commit-check-result + path: commit-check-result/ ``` > 📄 Full file: [`examples/commit-check-workflow-a.yml`](../examples/commit-check-workflow-a.yml) @@ -90,25 +120,26 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write - actions: read # needed to download artifacts + actions: read # needed to download another run's artifact steps: + # Download by run id: the PR number travels inside the artifact because + # github.event.workflow_run.pull_requests is empty for fork PRs. - uses: actions/download-artifact@v4 with: - name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} + name: commit-check-result run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Read result and post PR comment + - name: Post or update the PR comment uses: actions/github-script@v7 with: script: | - // See examples/commit-check-workflow-b.yml for full script + // See examples/commit-check-workflow-b.yml for the 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 + const prNumber = Number(fs.readFileSync('pr-number', 'utf8').trim()); + const body = fs.readFileSync('report.md', 'utf8'); // posted verbatim + const MARKER = ''; + // Finds the comment that starts with MARKER and updates it, + // or creates one when there is none yet ``` > 📄 Full file: [`examples/commit-check-workflow-b.yml`](../examples/commit-check-workflow-b.yml) @@ -119,7 +150,7 @@ jobs: 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 +- The artifact only contains `report.md`, `result.json` and `pr-number` — no code or secrets --- diff --git a/examples/commit-check-workflow-a.yml b/examples/commit-check-workflow-a.yml index 8c6c2ba..9d74146 100644 --- a/examples/commit-check-workflow-a.yml +++ b/examples/commit-check-workflow-a.yml @@ -1,10 +1,12 @@ # 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. +# This workflow is triggered by pull_request and runs the checks. On a pull +# request from a fork its GITHUB_TOKEN is read-only, so it cannot comment; +# instead it saves the action's `report` and `result` outputs as an artifact +# for Workflow B (commit-check-comment.yml), which runs with write permissions +# in the base repository and posts the report as the PR comment. # -# 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 name: Commit Check @@ -15,19 +17,38 @@ on: jobs: check: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: commit-check/commit-check-action@v2 + id: commit-check 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 + # The action exits 1 on a failure, so both steps below need `always()` + # or the artifact is missing on exactly the runs that need a comment. + # The outputs are written before the action exits, so they are present + # on failing runs too; the second condition only skips the upload when + # the action itself could not run. + - name: Save the report for Workflow B + if: always() && steps.commit-check.outputs.report != '' + env: + REPORT: ${{ steps.commit-check.outputs.report }} + RESULT: ${{ steps.commit-check.outputs.result }} + PR_NUMBER: ${{ github.event.number }} + run: | + mkdir -p commit-check-result + printf '%s\n' "$REPORT" > commit-check-result/report.md + printf '%s\n' "$RESULT" > commit-check-result/result.json + printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - uses: actions/upload-artifact@v4 + if: always() && steps.commit-check.outputs.report != '' with: - name: commit-check-result-${{ github.event.number }} - path: result.txt + name: commit-check-result + path: commit-check-result/ diff --git a/examples/commit-check-workflow-b.yml b/examples/commit-check-workflow-b.yml index 09517b8..e34a871 100644 --- a/examples/commit-check-workflow-b.yml +++ b/examples/commit-check-workflow-b.yml @@ -1,14 +1,16 @@ -# Workflow B: Post PR comment after commit checks complete. +# Workflow B: Post the PR comment after Workflow A completes. # -# 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). +# This workflow is triggered by the workflow_run event from Workflow A. It +# runs in the base repository's context with the permissions granted below, +# which makes it safe for fork PRs: it never checks out the fork's code, it +# only downloads the artifact Workflow A saved and posts report.md verbatim. # # Prerequisites: -# - Workflow A (commit-check.yml) must exist and upload an artifact named -# commit-check-result- containing result.txt +# - Workflow A (commit-check.yml) must exist, be named "Commit Check", and +# upload an artifact named commit-check-result containing report.md and +# pr-number (see commit-check-workflow-a.yml). # -# 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 name: Commit Check Comment @@ -22,36 +24,40 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write - actions: read # needed to download artifacts + actions: read # needed to download another run's artifact steps: + # Download by run id: the PR number travels inside the artifact because + # github.event.workflow_run.pull_requests is empty for fork PRs. - uses: actions/download-artifact@v4 with: - name: commit-check-result-${{ github.event.workflow_run.pull_requests[0].number }} + name: commit-check-result run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Read result and post PR comment + - name: Post or update the 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 prNumber = Number(fs.readFileSync('pr-number', 'utf8').trim()); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + core.setFailed(`Artifact carries no pull request number (got ${JSON.stringify(prNumber)})`); + return; + } - const successTitle = '# Commit-Check ✔️'; - const failureTitle = '# Commit-Check ❌'; - const body = resultText - ? `${failureTitle}\n\`\`\`\n${resultText}\n\`\`\`` - : successTitle; + // report.md is the action's `report` output: the same Markdown the + // action posts itself, opening with the marker it uses to find its + // own comment. Posting it unchanged means a later run with + // `pr-comments: true` edits this comment instead of adding one. + const body = fs.readFileSync('report.md', 'utf8'); + const MARKER = ''; - const { data: comments } = await github.rest.issues.listComments({ + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: prNumber, + per_page: 100, }); - - const existing = comments.find(c => - c.body.startsWith(successTitle) || c.body.startsWith(failureTitle) - ); + const existing = comments.find(c => (c.body ?? '').startsWith(MARKER)); if (existing) { await github.rest.issues.updateComment({ diff --git a/main.py b/main.py index 32b9209..d96f951 100755 --- a/main.py +++ b/main.py @@ -8,6 +8,10 @@ * **step log** — grouped sections, then one ``::error`` annotation per finding * **job summary** — a Markdown policy report table * **PR comment** — a compact Markdown summary (idempotently updated) + +and exposes two action outputs: ``result`` (the check data as JSON) and +``report`` (the rendered Markdown, for workflows that post the comment +themselves). """ import json @@ -1195,6 +1199,10 @@ def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: # one value the reader has to act on and the cap can hide the reason. # - The step log renders the same tree (_render_scopes); it adds the docs URL, # which the Markdown report already carries on the rule ID in the table. +# - The whole report, marker to footer, is also written verbatim to the +# `report` action output beside the JSON `result`, so a workflow_run job can +# post it for a fork pull request and produce the same comment this action +# would have posted itself. # --------------------------------------------------------------------------- @@ -1295,9 +1303,21 @@ def add_job_summary(results: list[ScopeResult]) -> int: def set_result_output(results: list[ScopeResult]) -> None: - """Expose the structured results as the ``result`` action output. - - Uses the heredoc form of ``GITHUB_OUTPUT`` so multi-line JSON survives. + """Expose the results as the ``result`` and ``report`` action outputs. + + ``result`` is the structured JSON downstream steps gate on. ``report`` is + the rendered Markdown — the very text the job summary and the PR comment + show — for the one caller that cannot post it itself: a ``pull_request`` + run on a fork has a read-only token, so it hands the report to a + ``workflow_run`` job that posts it verbatim (docs/fork-pr-comments.md). + Shipping the text rather than making that job re-render the JSON keeps + the fork comment identical to every other one, marker and footer + included, so a later run with ``pr-comments: true`` adopts it. + + Uses the heredoc form of ``GITHUB_OUTPUT`` so multi-line values survive. + Neither value can contain a bare ``EOF`` line: JSON lines are quoted or + punctuation, and every line of user text in the report is indented or + sits inside a table row. """ output_path = os.getenv("GITHUB_OUTPUT") if not output_path: @@ -1318,6 +1338,9 @@ def set_result_output(results: list[ScopeResult]) -> None: f.write("result< bool: diff --git a/main_test.py b/main_test.py index e6a585a..73edc04 100644 --- a/main_test.py +++ b/main_test.py @@ -1727,6 +1727,20 @@ def test_failure_returns_nonzero(self): self.assertIn("❌", content) +def read_github_output(path: str) -> dict[str, str]: + """Parse a ``GITHUB_OUTPUT`` file of ``name< Date: Mon, 7 Sep 2026 05:10:43 +0000 Subject: [PATCH 02/11] fix(install): keep the venv and wheels out of the caller's workspace The composite step ran in $GITHUB_WORKSPACE and left `venv/` (63 MB) and fourteen wheels next to the caller's sources, where any later `git status`, linter or `upload-artifact: .` step saw them. It also downloaded the dependency closure twice: `pip download` fetched all fourteen wheels, then `pip install commit_check-*.whl pygithub-*.whl` resolved the twelve transitive ones against PyPI again, so the installed wheels were not provably the downloaded ones. And when `gh` was missing, as on a self-hosted runner, the failure read "Artifact verification failed", blaming the wheel for an absent tool. Work under $RUNNER_TEMP/commit-check-action instead: one `pip download -d` into it, an explicit `command -v gh` check with its own `::error::`, `gh attestation verify` on the single downloaded commit-check wheel, and an offline `pip install --no-index --find-links` of requirements.txt so the verified wheel is the installed one and nothing is fetched twice. The Windows/Linux activation logic and DEB_PYTHON_INSTALL_LAYOUT are unchanged. Simulated in a scratch workspace seeded with README.md: before, `ls -A` showed README.md, 14 wheels and venv; after, README.md alone, with venv and wheels/ under $RUNNER_TEMP and zero "Downloading" lines on a forced reinstall from the wheel directory. Point the README SLSA badge at the current verify block; it linked to line numbers in an April 2025 revision of action.yml that no longer match. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 2 +- action.yml | 39 ++++++++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8ee850b..3188282 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Used by](https://img.shields.io/static/v1?label=Used%20by&message=165&color=informational&logo=slickpic)](https://github.com/commit-check/commit-check-action/network/dependents) [![GitHub marketplace](https://img.shields.io/badge/Marketplace-commit--check--action-blue)](https://github.com/marketplace/actions/commit-check-action) [![commit-check](https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white&color=%232c9ccd)](https://github.com/commit-check/commit-check) -[![slsa-badge](https://slsa.dev/images/gh-badge-level3.svg?color=blue)](https://github.com/commit-check/commit-check-action/blob/a2873ca0482dd505c93fb51861c953e82fd0a186/action.yml#L59-L69) +[![slsa-badge](https://slsa.dev/images/gh-badge-level3.svg?color=blue)](https://github.com/commit-check/commit-check-action/blob/main/action.yml#L84-L94) [![codecov](https://codecov.io/gh/commit-check/commit-check-action/graph/badge.svg?token=QHUDSMJGS7)](https://codecov.io/gh/commit-check/commit-check-action) A GitHub Action for checking commit message formatting, branch naming, committer name, email, commit signoff, and more. diff --git a/action.yml b/action.yml index d874207..6dda441 100644 --- a/action.yml +++ b/action.yml @@ -58,31 +58,44 @@ runs: # Platform-specific settings if [[ "$RUNNER_OS" == "Windows" ]]; then PYTHON_CMD="python" - VENV_ACTIVATE="venv/Scripts/activate" else if [[ "$RUNNER_OS" == "Linux" ]]; then # https://github.com/pypa/setuptools/issues/3269 export DEB_PYTHON_INSTALL_LAYOUT=deb fi PYTHON_CMD="python3" - VENV_ACTIVATE="venv/bin/activate" fi - # Set up virtual environment - $PYTHON_CMD -m venv venv - source "$VENV_ACTIVATE" + # Everything the action installs lives under $RUNNER_TEMP, never in the + # caller's checkout: a later `git status`, linter or upload-artifact step + # must not see our venv or wheels. + WORK="$RUNNER_TEMP/commit-check-action" + mkdir -p "$WORK/wheels" + $PYTHON_CMD -m venv "$WORK/venv" + if [[ "$RUNNER_OS" == "Windows" ]]; then + source "$WORK/venv/Scripts/activate" + else + source "$WORK/venv/bin/activate" + fi - # Download artifact - $PYTHON_CMD -m pip download -r "$GITHUB_ACTION_PATH/requirements.txt" + # One download of the pinned closure, into the scratch dir. + $PYTHON_CMD -m pip download -q -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt" - # Verify artifact attestations - if ! gh attestation verify commit_check-*.whl -R commit-check/commit-check; then - echo "Artifact verification failed. Aborting installation." - exit 1 + # Verify the commit-check wheel's build provenance (PyGithub and the + # transitive wheels are pinned but not attested; see README "Runner requirements"). + if ! command -v gh >/dev/null 2>&1; then + echo "::error::gh CLI not found; it is required to verify the commit-check wheel attestation. Install it on this runner (preinstalled on GitHub-hosted runners)." + exit 1 + fi + WHEEL=$(ls "$WORK"/wheels/commit_check-*.whl) # exactly one: pip download of a == pin + if ! gh attestation verify "$WHEEL" -R commit-check/commit-check; then + echo "::error::Attestation verification failed for $(basename "$WHEEL"). Aborting installation." + exit 1 fi - # Install artifact - $PYTHON_CMD -m pip install commit_check-*.whl pygithub-*.whl + # Install offline from the wheels we already downloaded and verified: + # no second trip to PyPI, and the installed set is exactly the downloaded set. + $PYTHON_CMD -m pip install -q --no-cache-dir --no-index --find-links "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt" $PYTHON_CMD "$GITHUB_ACTION_PATH/main.py" env: From 182bf903976b366e61666bda3d70430e6b172233 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:13:43 +0000 Subject: [PATCH 03/11] fix(report): keep backticks and pipes in checked values from breaking the report The failure table wrapped the checked value in a single pair of backticks and escaped nothing. A commit subject such as `fix: handle `None` | retry`, the kind of thing a fix or docs commit says all the time, closed the code span at its own backtick and split the row at its pipe: GitHub rendered four cells against a three-column header, showed a fragment as the value, and discarded the rule link in the fourth column, the one thing the reader needed. The details block had the same class of bug one size up: its fixed ```text fence was closed by any value quoting a fence, and the rest of the report, footer included, spilled out as prose. Add _markdown_code() for the value cell: a fence one backtick longer than the longest run inside the value, a space of padding when the value starts or ends with a backtick (GFM strips it), and `|` escaped, which GFM honours inside a code span in a table cell. _markdown_details() picks its fence the same way over the whole tree. The row for the subject above now reads | Commit 1/1 (5584f46) | ``fix: handle `None` \| retry`` | [CC001 message](...) | and splits into exactly three cells. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- main.py | 42 +++++++++++++++++++++++++--- main_test.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index d96f951..d4b29fb 100755 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ import json import os +import re import subprocess import sys from dataclasses import dataclass, field @@ -970,7 +971,7 @@ def _markdown_table( if not entries and not raw_failure: continue value = _scope_value(scope) - value_display = f"`{value}`" if value else "\u2014" + value_display = _markdown_code(value) if value else "\u2014" if raw_failure: links = "_output could not be parsed \u2014 see details_" else: @@ -1017,12 +1018,40 @@ def _markdown_details(results: list[ScopeResult]) -> str: _failed, total = _check_counts(results) unit = "check" if total == 1 else "checks" label = f"Show all {total} {unit}" if total else "Show details" - lines = ["
", f"{label}", "", "```text"] - lines.extend(_render_tree(results, include_docs=False)) - lines.extend(["```", "", "
"]) + body = _render_tree(results, include_docs=False) + # The tree quotes commit subjects, errors and suggestions as they are. A + # value with three backticks in it (a `docs:` commit showing a fence) + # would close a fixed ``` fence and spill the rest of the report out as + # prose, so the fence is one longer than any backtick run inside. + fence = "`" * max(3, _longest_backtick_run(body) + 1) + lines = ["
", f"{label}", "", f"{fence}text"] + lines.extend(body) + lines.extend([fence, "", "
"]) return "\n".join(lines) +def _longest_backtick_run(lines: list[str]) -> int: + """Length of the longest run of consecutive backticks across ``lines``.""" + return max((len(m) for line in lines for m in re.findall(r"`+", line)), default=0) + + +def _markdown_code(value: str) -> str: + """A code span that survives a GFM table cell. + + The value is user text (a commit subject, a branch name, an author) and + commonly quotes code. A bare ```` `{value}` ```` breaks twice on that: a + backtick inside closes the span early, and an unescaped ``|`` splits the + cell, pushing the rule link into a fourth column the table drops. So the + span uses one more backtick than the longest run inside the value, pads + with a space when the value starts or ends with a backtick (GFM strips + one on each side, so the padding is invisible), and escapes ``|``, which + GFM honours even inside a code span when the span sits in a table cell. + """ + fence = "`" * (_longest_backtick_run([value]) + 1) + pad = " " if value.startswith("`") or value.endswith("`") else "" + return f"{fence}{pad}{value.replace('|', chr(92) + '|')}{pad}{fence}" + + def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: """First non-empty check value for a scope, trimmed to a single line. @@ -1197,6 +1226,11 @@ def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: # - Values are capped at 60 characters with a literal "..." suffix, except on a # failing scope, where the details block prints the value in full — it is the # one value the reader has to act on and the cap can hide the reason. +# - The Checked value cell is a code span whose backtick fence is longer than +# any backtick run in the value, with `|` escaped, so a subject that quotes +# code cannot close the span or split the row; the details fence likewise +# grows past any backtick run in the tree. Reading the raw Markdown, expect +# ``fix: handle `None` \| retry`` rather than `fix: handle `None` | retry`. # - The step log renders the same tree (_render_scopes); it adds the docs URL, # which the Markdown report already carries on the rule ID in the table. # - The whole report, marker to footer, is also written verbatim to the diff --git a/main_test.py b/main_test.py index 73edc04..bc35dfb 100644 --- a/main_test.py +++ b/main_test.py @@ -2588,6 +2588,84 @@ def test_add_job_summary_returns_success_for_a_skipped_run(self): self.assertEqual(rc, 0) +class TestMarkdownEscaping(unittest.TestCase): + """User text in the report must not break the Markdown that carries it. + + Commit subjects quote code, branch names and author names may contain + pipes; the table cell and the fenced details block have to survive both. + The checks below come from a real ``commit-check --format json`` run on + the subject ``fix: handle `None` | retry`` (all rules pass on it, so the + CC001 outcome is flipped to make a table row). + """ + + #: Splits a table row on the pipes that are cell separators, not on the + #: escaped ones inside a cell. + CELL_SEPARATOR = re.compile(r"(? main.ScopeResult: + return main.ScopeResult( + label="Commit 1/1", + sha=SHA_B, + checks=[ + make_check( + "message", + status="fail", + rule_id="CC001", + value=value, + error="The commit message should follow Conventional Commits.", + docs_url="https://commit-check.com/rules/#cc001", + ), + make_check("subject_imperative", rule_id="CC003", value=value), + ], + ) + + def test_markdown_code_wraps_backticks_and_escapes_pipes(self): + cases = { + "plain": "`plain`", + "a|b": "`a\\|b`", + "fix: handle `None` | retry": "``fix: handle `None` \\| retry``", + "`x`": "`` `x` ``", + "``x``": "``` ``x`` ```", + "x`": "`` x` ``", + } + for value, expected in cases.items(): + with self.subTest(value=value): + self.assertEqual(main._markdown_code(value), expected) + + def test_table_row_with_backticks_and_a_pipe_keeps_three_cells(self): + table = main._markdown_table([self._scope("fix: handle `None` | retry")]) + row = table.splitlines()[2] + # Strip the outer pipes before counting: "| a | b | c |" -> 3 cells. + cells = self.CELL_SEPARATOR.split(row.strip().strip("|")) + self.assertEqual(len(cells), 3, row) + self.assertEqual(cells[1].strip(), "``fix: handle `None` \\| retry``") + self.assertEqual( + cells[2].strip(), "[CC001 message](https://commit-check.com/rules/#cc001)" + ) + + def test_truncated_value_with_an_unbalanced_backtick_still_closes(self): + """The 60-char cap can cut inside a backtick run; the fence is chosen + after truncation, so the span still closes at the right place.""" + value = "fix: " + "`x`, " * 20 + truncated = main._scope_value(self._scope(value)) + self.assertTrue(truncated.endswith("`x..."), truncated) # cut mid-span + cell = main._markdown_code(truncated) + self.assertEqual(cell, f"``{truncated}``") + + def test_details_fence_outgrows_a_triple_backtick_in_the_value(self): + details = main._markdown_details([self._scope("docs: show ``` usage")]) + lines = details.splitlines() + self.assertEqual(lines[3], "````text") + self.assertEqual(lines[-3], "````") + self.assertIn("value: docs: show ``` usage", details) + + def test_details_fence_stays_three_backticks_for_ordinary_values(self): + details = main._markdown_details([pass_scope(value="feat: add `login`")]) + lines = details.splitlines() + self.assertEqual(lines[3], "```text") + self.assertEqual(lines[-3], "```") + + class TestSkipRenderingEdgeCases(unittest.TestCase): def test_failure_table_omits_skipped_scopes(self): """A skipped scope has no failed checks, so it must not get a row. From 073d579b6f65871cc695cc66beda545280b11a63 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:14:38 +0000 Subject: [PATCH 04/11] test: run the real commit-check binary once, unmocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 32 patches of main.subprocess.run feed back the JSON shape that make_check() hard-codes, so the suite passed with commit-check removed from PATH entirely. A renamed key or a new status in the CLI would have left 191 tests green while the action rendered "—" for every value, which is the class of bug the warn status already caused once. Add TestRealCommitCheckBinary: two run_check_json calls against the pinned CLI, one passing and one failing subject, asserting the key set, the status vocabulary, the rule-id and docs-url shapes, and that a ScopeResult and the rendered report built from the real output come out right. It is skipped with a clear reason when the binary is not on PATH; CI installs it from requirements.txt, so it always runs where it matters. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- main_test.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/main_test.py b/main_test.py index bc35dfb..167a662 100644 --- a/main_test.py +++ b/main_test.py @@ -5,6 +5,7 @@ import json import os import re +import shutil import sys import tempfile import unittest @@ -2692,3 +2693,77 @@ def test_step_log_partial_skip_does_not_claim_all_passed(self): out = buf.getvalue() self.assertIn("1 of 2 checks passed, 1 skipped", out) self.assertNotIn("all checks passed", out) + + +# --------------------------------------------------------------------------- +# Integration: the real commit-check binary +# --------------------------------------------------------------------------- + +#: The JSON shape ``make_check()`` hard-codes and every renderer reads. +CHECK_KEYS = { + "rule_id", + "check", + "status", + "value", + "error", + "suggest", + "fix", + "docs_url", +} +STATUSES = {"pass", "fail", "warn", "skip"} + + +@unittest.skipUnless( + shutil.which("commit-check"), + "commit-check CLI not on PATH (CI installs it from requirements.txt)", +) +class TestRealCommitCheckBinary(unittest.TestCase): + """Run the pinned commit-check once, unmocked. + + Every other test patches ``main.subprocess.run`` and feeds back the JSON + shape ``make_check()`` hard-codes, so a renamed key or a new status in + the CLI would leave the whole suite green while the action rendered "—" + for every value. This is the one place that drift can fail a build. CI + installs requirements.txt, so the binary is always present there; the + skip only spares a contributor running the suite without it. + """ + + def _run(self, message: str) -> tuple[int, dict]: + rc, data, raw = main.run_check_json(["--message"], input_text=message) + self.assertIsInstance(data, dict, f"CLI did not emit JSON:\n{raw}") + assert data is not None # for the type checker; asserted above + self.assertIn("checks", data) + self.assertTrue(data["checks"], "CLI reported no checks") + for check in data["checks"]: + self.assertEqual(set(check), CHECK_KEYS, check) + self.assertIn(check["status"], STATUSES, check) + self.assertRegex(check["rule_id"], r"^CC\d{3}$") + self.assertTrue( + check["docs_url"].startswith("https://commit-check.com/rules/#") + ) + return rc, data + + def test_passing_message(self): + rc, data = self._run("fix: handle the empty case\n") + self.assertEqual(rc, 0) + self.assertEqual(data["status"], "pass") + self.assertTrue(all(c["status"] == "pass" for c in data["checks"])) + scope = main.ScopeResult(label="Commit 1/1", checks=data["checks"]) + self.assertEqual(scope.status, "pass") + self.assertEqual(main.overall_status([scope]), "pass") + + def test_failing_message(self): + rc, data = self._run("Bad subject\n") + self.assertEqual(rc, 1) + self.assertEqual(data["status"], "fail") + failed = [c for c in data["checks"] if c["status"] == "fail"] + self.assertEqual([c["rule_id"] for c in failed], ["CC001"]) + self.assertTrue(failed[0]["error"]) + scope = main.ScopeResult(label="Commit 1/1", checks=data["checks"]) + self.assertEqual(scope.status, "fail") + self.assertEqual(main.overall_status([scope]), "fail") + # The rendered report must carry the rule link the CLI supplied. + self.assertIn( + "[CC001 message](https://commit-check.com/rules/#cc001)", + main.render_report([scope]), + ) From 3d187db293d428d35635f4a456c9c5af729dc70f Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:17:08 +0000 Subject: [PATCH 05/11] fix(pr-comments): skip the comment for Dependabot pull requests too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_fork_pr_with_readonly_token() compared head and base repositories, so a Dependabot pull request, whose branch lives in the repository itself, went on to the API with the read-only token GitHub hands Dependabot-triggered pull_request runs, took a 403, and logged "Ensure your workflow grants 'pull-requests: write' permission" — advice the workflow had already followed, on every Dependabot PR. Treat GITHUB_ACTOR == dependabot[bot] as read-only under pull_request (pull_request_target keeps its configured permissions, as for forks), so Dependabot takes the same graceful skip as fork PRs, and word the warning and the job-summary notice for both cases. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- docs/fork-pr-comments.md | 5 +++- main.py | 41 ++++++++++++++++++---------- main_test.py | 59 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md index 3f95996..e8cc888 100644 --- a/docs/fork-pr-comments.md +++ b/docs/fork-pr-comments.md @@ -2,7 +2,10 @@ 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. +This means `pr-comments: true` cannot write a comment back to the PR. Pull requests +opened by **Dependabot** are in the same position: GitHub runs Dependabot-triggered +`pull_request` workflows with a read-only token even though the branch lives in your own +repository, so everything below applies to them too. By default, commit-check-action handles this gracefully: diff --git a/main.py b/main.py index d4b29fb..af8f794 100755 --- a/main.py +++ b/main.py @@ -1395,14 +1395,26 @@ def is_fork_pr() -> bool: return False +#: The actor GitHub reports for pull requests Dependabot opens. +DEPENDABOT_ACTOR = "dependabot[bot]" + + 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. + """Returns True when this run's GITHUB_TOKEN cannot write to the pull request. Under the pull_request event, GITHUB_TOKEN is read-only for fork PRs. + GitHub runs Dependabot-triggered pull_request workflows with a read-only + token as well, although a Dependabot branch lives in the repository + itself, so those runs take the same path: judged by ``is_fork_pr()`` + alone the action tried to comment, got a 403, and advised granting + ``pull-requests: write`` — which the workflow had already done. + Under pull_request_target, GITHUB_TOKEN has the workflow's configured - permissions regardless of whether the PR is from a fork. + permissions regardless of where, or from whom, the PR came. """ - return is_fork_pr() and os.getenv("GITHUB_EVENT_NAME", "") != "pull_request_target" + if os.getenv("GITHUB_EVENT_NAME", "") == "pull_request_target": + return False + return is_fork_pr() or os.getenv("GITHUB_ACTOR", "") == DEPENDABOT_ACTOR def get_pr_number() -> int: @@ -1471,16 +1483,16 @@ def add_pr_comments(results: list[ScopeResult]) -> int: print("Skipping PR comment: not a pull request event.") return 0 - # Fork PRs triggered by the pull_request event receive a read-only token; - # the GitHub API will always reject comment writes with 403. - # pull_request_target events always have the configured token permissions. + # Fork PRs and Dependabot PRs triggered by the pull_request event receive + # a read-only token; the GitHub API will always reject comment writes with + # 403. pull_request_target events always have the configured 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 " - "read-only for forks). " + "Skipping PR comment: GITHUB_TOKEN is read-only for this run. " + "Pull requests from forked repositories, and pull requests opened " + "by Dependabot, cannot write comments via the pull_request event. " "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." + "for how to enable PR comments on them." ) print(f"::warning::{msg}") if JOB_SUMMARY_ENABLED and GITHUB_STEP_SUMMARY: @@ -1488,10 +1500,11 @@ def add_pr_comments(results: list[ScopeResult]) -> int: 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 " + "Pull requests from forked repositories, and pull requests " + "opened by Dependabot, cannot write comments using the " + "`pull_request` event because `GITHUB_TOKEN` has read-only " + "permissions.\n\n" + "> **\U0001f4a1 Tip:** To enable PR comments on them, see " "[Enabling PR Comments on Fork Pull Requests]" "(https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md).\n" ) diff --git a/main_test.py b/main_test.py index 167a662..9d8cbfa 100644 --- a/main_test.py +++ b/main_test.py @@ -1843,6 +1843,30 @@ def test_push_event_skips_comment_without_warning(self): [line for line in printed if line.startswith("::warning")], printed ) + def test_dependabot_pr_takes_the_skip_path_and_names_dependabot(self): + with ( + patch("main.PR_COMMENTS_ENABLED", True), + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_ACTOR": "dependabot[bot]", + "GITHUB_REF": "refs/pull/12/merge", + }, + ), + patch("main.is_fork_pr", return_value=False), + patch("main.JOB_SUMMARY_ENABLED", False), + patch("main.get_pr_number") as mock_number, + patch("builtins.print") as mock_print, + ): + rc = main.add_pr_comments([fail_scope()]) + self.assertEqual(rc, 0) + mock_number.assert_not_called() # never reached the API + warning = mock_print.call_args_list[0][0][0] + self.assertTrue(warning.startswith("::warning::Skipping PR comment"), warning) + self.assertIn("Dependabot", warning) + self.assertNotIn("403", warning) + def test_fork_pr_skips_comment_and_warns(self): with ( patch("main.PR_COMMENTS_ENABLED", True), @@ -2089,7 +2113,40 @@ def test_fork_pr_with_pull_request_target_event(self): 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"}), + patch.dict( + os.environ, + {"GITHUB_EVENT_NAME": "pull_request", "GITHUB_ACTOR": "octocat"}, + ), + ): + self.assertFalse(main.is_fork_pr_with_readonly_token()) + + def test_dependabot_pull_request_has_a_readonly_token(self): + """A Dependabot branch is in the same repository, so is_fork_pr() is + False, yet GitHub hands its pull_request runs a read-only token. It + used to fall through to the API, take a 403, and tell the user to + grant a permission the workflow already had.""" + with ( + patch("main.is_fork_pr", return_value=False), + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_ACTOR": "dependabot[bot]", + }, + ), + ): + self.assertTrue(main.is_fork_pr_with_readonly_token()) + + def test_dependabot_pull_request_target_has_a_write_token(self): + with ( + patch("main.is_fork_pr", return_value=False), + patch.dict( + os.environ, + { + "GITHUB_EVENT_NAME": "pull_request_target", + "GITHUB_ACTOR": "dependabot[bot]", + }, + ), ): self.assertFalse(main.is_fork_pr_with_readonly_token()) From 781a040dc53eb90723367eea14280822d6db2969 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:17:08 +0000 Subject: [PATCH 06/11] docs(readme): explain fetch-depth, runner requirements, Dependabot, and the alternatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage snippet's fetch-depth comment did not say what a shallow clone costs: the action falls back to GitHub's merge commit, whose subject passes the default rules, so every PR looks green and author checks are skipped. Spell that out in the comment and a WARNING callout, with the exact annotation the run carries. Add a "Runner requirements" section (Python 3.10+, the gh CLI for the attestation check, PyPI and api.github.com access, git; which wheels are and are not attested; no input skips verification) — action.yml now points self-hosted operators here. Add the "Action, pre-commit hook, or GitHub App" comparison so evaluators can pick, and note under pr-comments that Dependabot pull requests are skipped like forks because GitHub runs their pull_request workflows with a read-only token. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3188282..00b2d4d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A GitHub Action for checking commit message formatting, branch naming, committer ## Table of Contents * [Usage](#usage) +* [Action, pre-commit hook, or GitHub App — which to use?](#action-pre-commit-hook-or-github-app--which-to-use) * [Optional Inputs](#optional-inputs) * [GitHub Action Job Summary](#github-action-job-summary) * [GitHub Pull Request Comments](#github-pull-request-comments) @@ -52,7 +53,10 @@ jobs: steps: - uses: actions/checkout@v7 with: - fetch-depth: 0 # With a shallow clone only HEAD, the merge commit, is checked + # Required. With the default fetch-depth: 1 the clone holds only GitHub's + # merge commit: the PR's own commits cannot be listed, author checks are + # skipped, and the action warns and falls back to checking HEAD alone. + fetch-depth: 0 - uses: commit-check/commit-check-action@v2 with: message: true @@ -63,9 +67,52 @@ jobs: pr-comments: true ``` +> [!WARNING] +> Without `fetch-depth: 0` the action still runs, but it cannot see the pull +> request's commits. It posts `::warning title=commit-check::Could not list the +> pull request's commits (is actions/checkout using fetch-depth: 0?); only HEAD +> was checked` and checks only the synthetic merge commit — whose subject +> `Merge into ` passes the default rules — so a shallow clone makes +> every PR look green. Author checks are skipped (`⊘`) for the same reason. On +> `pull_request_target`, also check out `refs/pull//merge`. + > [!NOTE] > This action supports running on Linux, macOS, and Windows (`ubuntu-latest`, `macos-latest`, `windows-latest`). +### Runner requirements + +The action is a composite step and uses what the runner already has: + +- **Python 3.10 or newer** on `PATH` (`python3`, or `python` on Windows). No + `setup-python` step is needed on GitHub-hosted runners. Everything the action + installs goes under `$RUNNER_TEMP`, never into your checkout. +- **`gh` CLI** — used to verify the build-provenance attestation of the + `commit-check` wheel before installing it. Present on GitHub-hosted images; + install it on self-hosted runners or the step fails with `gh CLI not found`. + Only the `commit-check` wheel is attested; PyGithub and the transitive + dependencies are pinned by `requirements.txt` but not verified. +- **Network access to PyPI and `api.github.com`** — the pinned wheels are + downloaded once per run and the attestation is fetched from GitHub. +- **`git`** on `PATH`, and a checkout with `fetch-depth: 0` (see above). + +There is currently no input to skip attestation verification. + +## Action, pre-commit hook, or GitHub App — which to use? + +All three run the same `commit-check` engine against the same +`commit-check.toml` / `cchk.toml`; they differ in where they run and what they +can see. + +| | GitHub Action (this repo) | [pre-commit hook](https://github.com/commit-check/commit-check#use-with-pre-commit) | [Commit Check GitHub App](https://github.com/marketplace/commit-check) | +|---|---|---|---| +| **Where it runs** | In your workflow, on the runner, after the push | On the contributor's machine, at `git commit` / `git push` | Hosted by commit-check; installed on the repository, no workflow file | +| **What it checks** | Every PR commit, the PR title, branch and author; renders a job summary, annotations, a PR comment and the `result` / `report` outputs | Message (`commit-msg` stage), branch, author; tag, force-push and files (`pre-push`) — one commit at a time, before it exists | The pull request's commits, title and branch, reported on the pull request | +| **When to pick it** | You want enforcement in CI that a contributor cannot skip, per-rule outputs for later steps, or you run on GitHub Enterprise Server / need `CCHK_*` overrides | You want the fastest feedback and to stop bad commits before they are pushed; pair it with the Action, since hooks are opt-in | You want zero YAML and no Actions minutes; fork PRs get comments without the [two-workflow pattern](docs/fork-pr-comments.md) | + +Most teams pair the pre-commit hook (fast, local) with the Action (enforced): +the hook catches a bad message before it is pushed, and the Action is why CI +fails when a contributor did not install the hook. + ## Used By

@@ -137,6 +184,15 @@ jobs: > [docs/fork-pr-comments.md](docs/fork-pr-comments.md) for details on how to enable > this feature for fork contributions. > +> **Dependabot pull requests** are skipped the same way. GitHub runs +> Dependabot-triggered `pull_request` workflows with a read-only token even +> when the workflow requests `pull-requests: write`, so the permission is not +> the problem: the action logs a `::warning::`, leaves the report in the job +> summary, and the step still exits by the checks' result. To comment on them +> too, use the [two-workflow pattern](docs/fork-pr-comments.md), or run on +> `pull_request_target` for Dependabot only. Adding `dependabot[bot]` to +> `ignore_authors` skips the checks for those PRs altogether. +> > Note: write-access to pull-requests requires the `pull-requests: write` permission. > See [usage example](#usage). From 550e510567a804bc1f598e2d6e025b0c242f6abb Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:22:06 +0000 Subject: [PATCH 07/11] fix(pr-comments): Dependabot runs honour the permissions key, so do not skip them Reverts the Dependabot special case: since October 2021 workflows triggered by Dependabot pull requests respect the workflow's permissions key, so a workflow that grants pull-requests: write can comment on them. Treating them as read-only would have silenced the comment exactly where it works. The README now says what is true: the token is read-only by default, the permissions key lifts that, and Actions secrets are unavailable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 16 +++++------ docs/fork-pr-comments.md | 5 +--- main.py | 41 ++++++++++------------------ main_test.py | 59 +--------------------------------------- 4 files changed, 24 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 00b2d4d..ed198a6 100644 --- a/README.md +++ b/README.md @@ -184,14 +184,14 @@ fails when a contributor did not install the hook. > [docs/fork-pr-comments.md](docs/fork-pr-comments.md) for details on how to enable > this feature for fork contributions. > -> **Dependabot pull requests** are skipped the same way. GitHub runs -> Dependabot-triggered `pull_request` workflows with a read-only token even -> when the workflow requests `pull-requests: write`, so the permission is not -> the problem: the action logs a `::warning::`, leaves the report in the job -> summary, and the step still exits by the checks' result. To comment on them -> too, use the [two-workflow pattern](docs/fork-pr-comments.md), or run on -> `pull_request_target` for Dependabot only. Adding `dependabot[bot]` to -> `ignore_authors` skips the checks for those PRs altogether. +> **Dependabot pull requests** are not forks, but GitHub gives their +> `pull_request` runs a read-only `GITHUB_TOKEN` by default. The `permissions` +> key is honoured for them, so the `pull-requests: write` grant in the +> [usage example](#usage) is enough; without it the action logs a +> `::warning::` on the 403 and leaves the report in the job summary. Note that +> Actions secrets are not available in Dependabot-triggered runs. Adding +> `dependabot[bot]` to `ignore_authors` skips the checks for those PRs +> altogether. > > Note: write-access to pull-requests requires the `pull-requests: write` permission. > See [usage example](#usage). diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md index e8cc888..3f95996 100644 --- a/docs/fork-pr-comments.md +++ b/docs/fork-pr-comments.md @@ -2,10 +2,7 @@ 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. Pull requests -opened by **Dependabot** are in the same position: GitHub runs Dependabot-triggered -`pull_request` workflows with a read-only token even though the branch lives in your own -repository, so everything below applies to them too. +This means `pr-comments: true` cannot write a comment back to the PR. By default, commit-check-action handles this gracefully: diff --git a/main.py b/main.py index af8f794..d4b29fb 100755 --- a/main.py +++ b/main.py @@ -1395,26 +1395,14 @@ def is_fork_pr() -> bool: return False -#: The actor GitHub reports for pull requests Dependabot opens. -DEPENDABOT_ACTOR = "dependabot[bot]" - - def is_fork_pr_with_readonly_token() -> bool: - """Returns True when this run's GITHUB_TOKEN cannot write to the pull request. + """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. - GitHub runs Dependabot-triggered pull_request workflows with a read-only - token as well, although a Dependabot branch lives in the repository - itself, so those runs take the same path: judged by ``is_fork_pr()`` - alone the action tried to comment, got a 403, and advised granting - ``pull-requests: write`` — which the workflow had already done. - Under pull_request_target, GITHUB_TOKEN has the workflow's configured - permissions regardless of where, or from whom, the PR came. + permissions regardless of whether the PR is from a fork. """ - if os.getenv("GITHUB_EVENT_NAME", "") == "pull_request_target": - return False - return is_fork_pr() or os.getenv("GITHUB_ACTOR", "") == DEPENDABOT_ACTOR + return is_fork_pr() and os.getenv("GITHUB_EVENT_NAME", "") != "pull_request_target" def get_pr_number() -> int: @@ -1483,16 +1471,16 @@ def add_pr_comments(results: list[ScopeResult]) -> int: print("Skipping PR comment: not a pull request event.") return 0 - # Fork PRs and Dependabot PRs triggered by the pull_request event receive - # a read-only token; the GitHub API will always reject comment writes with - # 403. pull_request_target events always have the configured permissions. + # Fork PRs triggered by the pull_request event receive a read-only token; + # the GitHub API will always reject comment writes with 403. + # pull_request_target events always have the configured token permissions. if is_fork_pr_with_readonly_token(): msg = ( - "Skipping PR comment: GITHUB_TOKEN is read-only for this run. " - "Pull requests from forked repositories, and pull requests opened " - "by Dependabot, cannot write comments via the pull_request event. " + "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/blob/main/docs/fork-pr-comments.md " - "for how to enable PR comments on them." + "for how to enable PR comments on fork PRs." ) print(f"::warning::{msg}") if JOB_SUMMARY_ENABLED and GITHUB_STEP_SUMMARY: @@ -1500,11 +1488,10 @@ def add_pr_comments(results: list[ScopeResult]) -> int: f.write( "\n---\n" "### \u2139\ufe0f PR Comment Skipped\n\n" - "Pull requests from forked repositories, and pull requests " - "opened by Dependabot, cannot write comments using the " - "`pull_request` event because `GITHUB_TOKEN` has read-only " - "permissions.\n\n" - "> **\U0001f4a1 Tip:** To enable PR comments on them, see " + "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/blob/main/docs/fork-pr-comments.md).\n" ) diff --git a/main_test.py b/main_test.py index 9d8cbfa..167a662 100644 --- a/main_test.py +++ b/main_test.py @@ -1843,30 +1843,6 @@ def test_push_event_skips_comment_without_warning(self): [line for line in printed if line.startswith("::warning")], printed ) - def test_dependabot_pr_takes_the_skip_path_and_names_dependabot(self): - with ( - patch("main.PR_COMMENTS_ENABLED", True), - patch.dict( - os.environ, - { - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_ACTOR": "dependabot[bot]", - "GITHUB_REF": "refs/pull/12/merge", - }, - ), - patch("main.is_fork_pr", return_value=False), - patch("main.JOB_SUMMARY_ENABLED", False), - patch("main.get_pr_number") as mock_number, - patch("builtins.print") as mock_print, - ): - rc = main.add_pr_comments([fail_scope()]) - self.assertEqual(rc, 0) - mock_number.assert_not_called() # never reached the API - warning = mock_print.call_args_list[0][0][0] - self.assertTrue(warning.startswith("::warning::Skipping PR comment"), warning) - self.assertIn("Dependabot", warning) - self.assertNotIn("403", warning) - def test_fork_pr_skips_comment_and_warns(self): with ( patch("main.PR_COMMENTS_ENABLED", True), @@ -2113,40 +2089,7 @@ def test_fork_pr_with_pull_request_target_event(self): 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", "GITHUB_ACTOR": "octocat"}, - ), - ): - self.assertFalse(main.is_fork_pr_with_readonly_token()) - - def test_dependabot_pull_request_has_a_readonly_token(self): - """A Dependabot branch is in the same repository, so is_fork_pr() is - False, yet GitHub hands its pull_request runs a read-only token. It - used to fall through to the API, take a 403, and tell the user to - grant a permission the workflow already had.""" - with ( - patch("main.is_fork_pr", return_value=False), - patch.dict( - os.environ, - { - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_ACTOR": "dependabot[bot]", - }, - ), - ): - self.assertTrue(main.is_fork_pr_with_readonly_token()) - - def test_dependabot_pull_request_target_has_a_write_token(self): - with ( - patch("main.is_fork_pr", return_value=False), - patch.dict( - os.environ, - { - "GITHUB_EVENT_NAME": "pull_request_target", - "GITHUB_ACTOR": "dependabot[bot]", - }, - ), + patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}), ): self.assertFalse(main.is_fork_pr_with_readonly_token()) From 6ab8f22af15cf48e7a71835d33d485a2d9e2d6de Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:28:36 +0000 Subject: [PATCH 08/11] refactor(install): drop the gh presence check, hosted runners always have it GitHub-hosted runners ship gh; only a self-hosted runner can lack it, and there the attestation step fails anyway. The failure message now carries that hint instead of a separate pre-check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 2 +- action.yml | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ed198a6..24d5c61 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ The action is a composite step and uses what the runner already has: installs goes under `$RUNNER_TEMP`, never into your checkout. - **`gh` CLI** — used to verify the build-provenance attestation of the `commit-check` wheel before installing it. Present on GitHub-hosted images; - install it on self-hosted runners or the step fails with `gh CLI not found`. + install it on self-hosted runners or the attestation step fails. Only the `commit-check` wheel is attested; PyGithub and the transitive dependencies are pinned by `requirements.txt` but not verified. - **Network access to PyPI and `api.github.com`** — the pinned wheels are diff --git a/action.yml b/action.yml index 6dda441..64f1dc9 100644 --- a/action.yml +++ b/action.yml @@ -83,13 +83,9 @@ runs: # Verify the commit-check wheel's build provenance (PyGithub and the # transitive wheels are pinned but not attested; see README "Runner requirements"). - if ! command -v gh >/dev/null 2>&1; then - echo "::error::gh CLI not found; it is required to verify the commit-check wheel attestation. Install it on this runner (preinstalled on GitHub-hosted runners)." - exit 1 - fi WHEEL=$(ls "$WORK"/wheels/commit_check-*.whl) # exactly one: pip download of a == pin if ! gh attestation verify "$WHEEL" -R commit-check/commit-check; then - echo "::error::Attestation verification failed for $(basename "$WHEEL"). Aborting installation." + echo "::error::Attestation verification failed for $(basename "$WHEEL") (self-hosted runners need the gh CLI). Aborting installation." exit 1 fi From f1849d9c391f8c00b4ef4133fb9c1d620a6c59c2 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 05:43:26 +0000 Subject: [PATCH 09/11] fix(output): random heredoc delimiter, so an EOF line in a commit body cannot break outputs On a failing message rule the report quotes the commit message in full, and a body line reading EOF is legal. With the fixed EOF delimiter the runner rejected GITHUB_OUTPUT, failed the step and dropped the report output. Each value now gets a per-write random delimiter, the shape actions/github-script uses. Multi-line values and suggestions are split into one tree row per line, like errors and fixes already were, so no user text lands at column 0. The test parser is a port of the runner's file-command loop and raises on a file the runner would reject; a new test writes a value with a bare EOF line, a CRLF EOF line and an EOF line in the suggestion. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- main.py | 44 ++++++++++++++++++++--------- main_test.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/main.py b/main.py index d4b29fb..58b4fed 100755 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ import re import subprocess import sys +import uuid from dataclasses import dataclass, field from typing import Any @@ -691,17 +692,23 @@ def _finding_lines(check: dict[str, str], include_error: bool) -> list[str]: so printing both would say the same thing twice in a row; in exactly that case only ``Fix:`` is shown. A multi-line fix (a signed-off body) takes one row per line so the trailer lands where it would in the - message. + message; a multi-line value (a whole commit message on a failing rule) + or suggestion is split the same way, so every line of user text sits + inside the tree rather than at column 0. """ lines: list[str] = [] if check.get("value"): - lines.append(f"value: {check['value']}") + first, *rest = str(check["value"]).splitlines() + lines.append(f"value: {first}") + lines.extend(rest) if include_error: lines.extend(check.get("error", "").splitlines()) fix = check.get("fix", "") suggest = check.get("suggest", "") if suggest and suggest != f'Use "{fix}"': - lines.append(f"Suggest: {suggest}") + first, *rest = suggest.splitlines() + lines.append(f"Suggest: {first}") + lines.extend(rest) if fix: first, *rest = fix.splitlines() lines.append(f"Fix: {first}") @@ -1348,10 +1355,9 @@ def set_result_output(results: list[ScopeResult]) -> None: the fork comment identical to every other one, marker and footer included, so a later run with ``pr-comments: true`` adopts it. - Uses the heredoc form of ``GITHUB_OUTPUT`` so multi-line values survive. - Neither value can contain a bare ``EOF`` line: JSON lines are quoted or - punctuation, and every line of user text in the report is indented or - sits inside a table row. + Both values are written through :func:`_write_output`, whose per-write + random delimiter keeps a line of user text (a commit body line reading + ``EOF``, say) from closing the heredoc early. """ output_path = os.getenv("GITHUB_OUTPUT") if not output_path: @@ -1369,12 +1375,24 @@ def set_result_output(results: list[ScopeResult]) -> None: ], } with open(output_path, "a", encoding="utf-8") as f: - f.write("result< None: + """Append one multi-line output in the heredoc form ``GITHUB_OUTPUT`` takes. + + The runner reads lines up to the first one equal to the delimiter and + rejects the whole file if it never finds one, which fails the step and + drops every output written after the bad one. A fixed ``EOF`` delimiter + is therefore unsafe for the report: on a failing rule it quotes the + commit message in full, and a body line reading ``EOF`` is legal. So the + delimiter is random per write, the shape actions/github-script uses. + """ + delimiter = f"ghadelimiter_{uuid.uuid4()}" + while delimiter in value: # pragma: no cover - 122 random bits + delimiter = f"ghadelimiter_{uuid.uuid4()}" + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") def is_fork_pr() -> bool: diff --git a/main_test.py b/main_test.py index 167a662..e3e28dd 100644 --- a/main_test.py +++ b/main_test.py @@ -1729,16 +1729,43 @@ def test_failure_returns_nonzero(self): def read_github_output(path: str) -> dict[str, str]: - """Parse a ``GITHUB_OUTPUT`` file of ``name<= 0 and (heredoc < 0 or equals < heredoc): + name, value = line.split("=", 1) + outputs[name] = value + continue + if heredoc < 0: + raise ValueError(f"Invalid format '{line}'") + name, delimiter = line.split("<<", 1) + if not delimiter: + raise ValueError("Invalid format: empty delimiter") + body: list[str] = [] + while True: + if index >= len(lines): + raise ValueError(f"Matching delimiter not found '{delimiter}'") + current = lines[index] + index += 1 + if current == delimiter: + break + body.append(current) + outputs[name] = "\n".join(body) return outputs @@ -1749,10 +1776,45 @@ def test_writes_heredoc_json(self): main.set_result_output([fail_scope("Commit 1/1"), pass_scope("Branch")]) with open(output_path, encoding="utf-8") as file_obj: content = file_obj.read() - self.assertIn("result< Date: Mon, 7 Sep 2026 05:43:26 +0000 Subject: [PATCH 10/11] fix(install): start each invocation from an empty work dir, wheels only; doc fixes - action.yml removes $RUNNER_TEMP/commit-check-action before use, so a second invocation in one job cannot leave two commit_check wheels for the attestation glob; pip download takes --only-binary=:all: so a platform without wheels fails at download time with pip's message instead of at the offline install. - README comparison table: the Action's title and author checks are opt-in; the GitHub App reports one check run per commit and posts no comment. - Workflow A, docs and README write report.md with printf '%s' so the posted comment is byte-identical to the report output. - Workflow B tolerates a missing artifact (A's install failed) with a notice instead of a red run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 6 +++--- action.yml | 7 +++++-- docs/fork-pr-comments.md | 5 ++++- examples/commit-check-workflow-a.yml | 2 +- examples/commit-check-workflow-b.yml | 10 +++++++++- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 24d5c61..483a15d 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ can see. | | GitHub Action (this repo) | [pre-commit hook](https://github.com/commit-check/commit-check#use-with-pre-commit) | [Commit Check GitHub App](https://github.com/marketplace/commit-check) | |---|---|---|---| | **Where it runs** | In your workflow, on the runner, after the push | On the contributor's machine, at `git commit` / `git push` | Hosted by commit-check; installed on the repository, no workflow file | -| **What it checks** | Every PR commit, the PR title, branch and author; renders a job summary, annotations, a PR comment and the `result` / `report` outputs | Message (`commit-msg` stage), branch, author; tag, force-push and files (`pre-push`) — one commit at a time, before it exists | The pull request's commits, title and branch, reported on the pull request | -| **When to pick it** | You want enforcement in CI that a contributor cannot skip, per-rule outputs for later steps, or you run on GitHub Enterprise Server / need `CCHK_*` overrides | You want the fastest feedback and to stop bad commits before they are pushed; pair it with the Action, since hooks are opt-in | You want zero YAML and no Actions minutes; fork PRs get comments without the [two-workflow pattern](docs/fork-pr-comments.md) | +| **What it checks** | Every PR commit's message, plus the PR title, branch and author checks you enable; renders a job summary, annotations, a PR comment and the `result` / `report` outputs | Message (`commit-msg` stage), branch, author; tag, force-push and files (`pre-push`) — one commit at a time, before it exists | Every commit of a push or pull request: message, branch, author (the PR title only in squash mode); reported as one **Commit Check** check run per commit | +| **When to pick it** | You want enforcement in CI that a contributor cannot skip, per-rule outputs for later steps, or you run on GitHub Enterprise Server / need `CCHK_*` overrides | You want the fastest feedback and to stop bad commits before they are pushed; pair it with the Action, since hooks are opt-in | You want zero YAML and no Actions minutes; fork PRs get a check run without the [two-workflow pattern](docs/fork-pr-comments.md) (no PR comment, though) | Most teams pair the pre-commit hook (fast, local) with the Action (enforced): the hook catches a bad message before it is pushed, and the Action is why CI @@ -330,7 +330,7 @@ action's own, that comment is later found and edited in place like any other. if: always() && steps.commit-check.outputs.report != '' env: REPORT: ${{ steps.commit-check.outputs.report }} - run: printf '%s\n' "$REPORT" > report.md + run: printf '%s' "$REPORT" > report.md ``` Treat `report` as text to display, not data to parse; `result` is the contract diff --git a/action.yml b/action.yml index 64f1dc9..d735bd7 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,7 @@ runs: # caller's checkout: a later `git status`, linter or upload-artifact step # must not see our venv or wheels. WORK="$RUNNER_TEMP/commit-check-action" + rm -rf "$WORK" # a second invocation in the same job starts clean, so the wheel glob below matches exactly one file mkdir -p "$WORK/wheels" $PYTHON_CMD -m venv "$WORK/venv" if [[ "$RUNNER_OS" == "Windows" ]]; then @@ -78,8 +79,10 @@ runs: source "$WORK/venv/bin/activate" fi - # One download of the pinned closure, into the scratch dir. - $PYTHON_CMD -m pip download -q -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt" + # One download of the pinned closure, into the scratch dir. Wheels only: + # the offline install below cannot build an sdist, so a platform without + # binary wheels fails here, with pip's message, rather than later. + $PYTHON_CMD -m pip download -q --only-binary=:all: -d "$WORK/wheels" -r "$GITHUB_ACTION_PATH/requirements.txt" # Verify the commit-check wheel's build provenance (PyGithub and the # transitive wheels are pinned but not attested; see README "Runner requirements"). diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md index 3f95996..7c1beaf 100644 --- a/docs/fork-pr-comments.md +++ b/docs/fork-pr-comments.md @@ -91,7 +91,7 @@ jobs: PR_NUMBER: ${{ github.event.number }} run: | mkdir -p commit-check-result - printf '%s\n' "$REPORT" > commit-check-result/report.md + printf '%s' "$REPORT" > commit-check-result/report.md printf '%s\n' "$RESULT" > commit-check-result/result.json printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - uses: actions/upload-artifact@v4 @@ -125,11 +125,14 @@ jobs: # Download by run id: the PR number travels inside the artifact because # github.event.workflow_run.pull_requests is empty for fork PRs. - uses: actions/download-artifact@v4 + id: download + continue-on-error: true # no artifact when A's install failed: nothing to post with: name: commit-check-result run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: Post or update the PR comment + if: steps.download.outcome == 'success' uses: actions/github-script@v7 with: script: | diff --git a/examples/commit-check-workflow-a.yml b/examples/commit-check-workflow-a.yml index 9d74146..920bce0 100644 --- a/examples/commit-check-workflow-a.yml +++ b/examples/commit-check-workflow-a.yml @@ -44,7 +44,7 @@ jobs: PR_NUMBER: ${{ github.event.number }} run: | mkdir -p commit-check-result - printf '%s\n' "$REPORT" > commit-check-result/report.md + printf '%s' "$REPORT" > commit-check-result/report.md printf '%s\n' "$RESULT" > commit-check-result/result.json printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - uses: actions/upload-artifact@v4 diff --git a/examples/commit-check-workflow-b.yml b/examples/commit-check-workflow-b.yml index e34a871..f14569a 100644 --- a/examples/commit-check-workflow-b.yml +++ b/examples/commit-check-workflow-b.yml @@ -27,14 +27,22 @@ jobs: actions: read # needed to download another run's artifact steps: # Download by run id: the PR number travels inside the artifact because - # github.event.workflow_run.pull_requests is empty for fork PRs. + # github.event.workflow_run.pull_requests is empty for fork PRs. The + # artifact is missing when Workflow A never got as far as running the + # checks (its install step failed); that is A's failure to show, not B's. - uses: actions/download-artifact@v4 + id: download + continue-on-error: true with: name: commit-check-result run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} + - if: steps.download.outcome != 'success' + run: echo "::notice::Workflow A (run ${{ github.event.workflow_run.id }}) uploaded no commit-check-result artifact; nothing to post." + - name: Post or update the PR comment + if: steps.download.outcome == 'success' uses: actions/github-script@v7 with: script: | From 0db5e821a3a371778aa2ab5b837056f96e4e3096 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Mon, 7 Sep 2026 08:16:49 +0000 Subject: [PATCH 11/11] refactor: drop the report output and the two-workflow fork pattern for the App A fork pull request only loses the comment: the check status, the annotations on the Files changed tab, the job summary and the result output all work, and pr-comments on a fork is a no-op rather than an error. The two-workflow pattern spent two files, an artifact round trip, actions: read and a smuggled PR number to move information the contributor could already see into a comment, and the report output existed only to feed it. The Commit Check GitHub App receives the pull_request webhook on the base repository and acts with its own token, so fork PRs are ordinary PRs to it: one check run per commit, no workflow file, free on public repositories, which is where fork PRs happen. docs/fork-pr-comments.md now says what a fork contributor actually sees, points at the App, and keeps pull_request_target with its security warning for anyone who cannot install one. The file name stays so links from released versions still resolve. The random heredoc delimiter stays: it protects the result output, whose JSON quotes commit subjects and error text as they are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018xYa7m3qup5wyN5MaXFgf6 --- README.md | 46 ++---- action.yml | 3 - docs/fork-pr-comments.md | 225 ++++++++------------------- examples/commit-check-workflow-a.yml | 54 ------- examples/commit-check-workflow-b.yml | 82 ---------- main.py | 47 ++---- main_test.py | 39 ++--- 7 files changed, 101 insertions(+), 395 deletions(-) delete mode 100644 examples/commit-check-workflow-a.yml delete mode 100644 examples/commit-check-workflow-b.yml diff --git a/README.md b/README.md index 483a15d..8208b68 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A GitHub Action for checking commit message formatting, branch naming, committer * [GitHub Action Job Summary](#github-action-job-summary) * [GitHub Pull Request Comments](#github-pull-request-comments) * [Advanced Configuration](#advanced-configuration) -* [Fork PR Comments](docs/fork-pr-comments.md) +* [Fork Pull Requests](docs/fork-pr-comments.md) * [Badging Your Repository](#badging-your-repository) * [Versioning](#versioning) @@ -106,8 +106,8 @@ can see. | | GitHub Action (this repo) | [pre-commit hook](https://github.com/commit-check/commit-check#use-with-pre-commit) | [Commit Check GitHub App](https://github.com/marketplace/commit-check) | |---|---|---|---| | **Where it runs** | In your workflow, on the runner, after the push | On the contributor's machine, at `git commit` / `git push` | Hosted by commit-check; installed on the repository, no workflow file | -| **What it checks** | Every PR commit's message, plus the PR title, branch and author checks you enable; renders a job summary, annotations, a PR comment and the `result` / `report` outputs | Message (`commit-msg` stage), branch, author; tag, force-push and files (`pre-push`) — one commit at a time, before it exists | Every commit of a push or pull request: message, branch, author (the PR title only in squash mode); reported as one **Commit Check** check run per commit | -| **When to pick it** | You want enforcement in CI that a contributor cannot skip, per-rule outputs for later steps, or you run on GitHub Enterprise Server / need `CCHK_*` overrides | You want the fastest feedback and to stop bad commits before they are pushed; pair it with the Action, since hooks are opt-in | You want zero YAML and no Actions minutes; fork PRs get a check run without the [two-workflow pattern](docs/fork-pr-comments.md) (no PR comment, though) | +| **What it checks** | Every PR commit's message, plus the PR title, branch and author checks you enable; renders a job summary, annotations, a PR comment and the `result` output | Message (`commit-msg` stage), branch, author; tag, force-push and files (`pre-push`) — one commit at a time, before it exists | Every commit of a push or pull request: message, branch, author (the PR title only in squash mode); reported as one **Commit Check** check run per commit | +| **When to pick it** | You want enforcement in CI that a contributor cannot skip, per-rule outputs for later steps, or you run on GitHub Enterprise Server / need `CCHK_*` overrides | You want the fastest feedback and to stop bad commits before they are pushed; pair it with the Action, since hooks are opt-in | You want zero YAML and no Actions minutes, or feedback on [fork pull requests](docs/fork-pr-comments.md) without the Action's read-only-token limits | Most teams pair the pre-commit hook (fast, local) with the Action (enforced): the hook catches a bad message before it is pushed, and the Action is why CI @@ -180,9 +180,10 @@ fails when a contributor did not install the hook. > [!NOTE] > `pr-comments` is disabled by default. > -> PR comments are skipped for pull requests from forked repositories. See -> [docs/fork-pr-comments.md](docs/fork-pr-comments.md) for details on how to enable -> this feature for fork contributions. +> PR comments are skipped for pull requests from forked repositories, whose +> `GITHUB_TOKEN` is read-only. Everything else still works there: the check +> status, the annotations and the job summary. See +> [Fork pull requests](docs/fork-pr-comments.md). > > **Dependabot pull requests** are not forks, but GitHub gives their > `pull_request` runs a read-only `GITHUB_TOKEN` by default. The `permissions` @@ -314,28 +315,6 @@ check outcomes (`rule_id`, `check`, `status`, `value`, `error`, `suggest`, `fix`, `docs_url`) exactly as produced by `commit-check --format json`, so downstream jobs can build their own reports or gate on individual rules. -### `report` - -The rendered Markdown report — byte for byte the text the -[job summary](#github-action-job-summary) and the -[PR comment](#github-pull-request-comments) show, opening with the -`` marker. It exists for workflows that have to post -the comment themselves: a `pull_request` run on a fork has a read-only token, so -it saves the report as an artifact and a `workflow_run` job posts it verbatim — -see [Fork PR Comments](docs/fork-pr-comments.md). Because the text is the -action's own, that comment is later found and edited in place like any other. - -```yaml -- name: Save the report - if: always() && steps.commit-check.outputs.report != '' - env: - REPORT: ${{ steps.commit-check.outputs.report }} - run: printf '%s' "$REPORT" > report.md -``` - -Treat `report` as text to display, not data to parse; `result` is the contract -for that. - ## GitHub Action Job Summary By default, commit-check-action results are shown on the job summary page of the @@ -541,10 +520,13 @@ By default, commit-check-action handles this gracefully: - 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, see -> the **[Fork PR Comments](docs/fork-pr-comments.md)** documentation for -> two recommended approaches with ready-to-use workflow examples. +> **For most projects, this is sufficient** — a fork contributor already gets the red +> check, the per-finding annotations on their diff and the full report in the job +> summary. If you want feedback on the pull request itself, the +> [Commit Check GitHub App](https://github.com/marketplace/commit-check) posts a check +> run per commit with no workflow file (free on public repositories), or you can run +> this action on `pull_request_target`. Both are covered in +> **[Fork pull requests](docs/fork-pr-comments.md)**. ## Badging Your Repository diff --git a/action.yml b/action.yml index d735bd7..153cb01 100644 --- a/action.yml +++ b/action.yml @@ -44,9 +44,6 @@ outputs: # mapping (and the step id it refers to) the output is always the empty # string, and fromJSON('') fails the calling workflow. value: ${{ steps.commit-check.outputs.result }} - report: - description: The rendered Markdown report, byte for byte what the job summary and PR comment show. Save it as an artifact from a pull_request run and post it verbatim from a workflow_run job to comment on fork pull requests (see docs/fork-pr-comments.md). - value: ${{ steps.commit-check.outputs.report }} runs: using: "composite" diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md index 7c1beaf..b288173 100644 --- a/docs/fork-pr-comments.md +++ b/docs/fork-pr-comments.md @@ -1,189 +1,77 @@ -# Fork PR Comments +# Fork Pull Requests -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. +When a pull request comes from a **forked repository**, the `GITHUB_TOKEN` of the +`pull_request` event is **read-only** by design (GitHub security policy). That single +restriction is worth understanding precisely, because it costs less than it sounds like. -By default, commit-check-action handles this gracefully: +## What a fork contributor still sees -- 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** +Everything except the comment. The action does not degrade on a fork PR: -> **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. +| Surface | Fork PR | Why | +|---|---|---| +| The check's pass/fail status | ✅ works | the job's own conclusion | +| `::error` annotations on the **Files changed** tab | ✅ works | workflow commands are written by the runner, not the API | +| The **job summary** — the full report table and details | ✅ works | `$GITHUB_STEP_SUMMARY` is a file on the runner | +| The `result` output for later steps | ✅ works | `$GITHUB_OUTPUT` is a file on the runner | +| A **PR comment** | ❌ skipped | writing a comment needs the API, and the token is read-only | ---- - -## 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:** - -Workflow A runs the checks with `pr-comments: false` and saves the action's -[`report`](../README.md#report) output — the rendered Markdown the job summary shows — plus -the [`result`](../README.md#result) JSON and the PR number, as an artifact. Workflow B, -triggered by `workflow_run` in the base repository, downloads the artifact and posts (or -updates) one comment carrying the `` marker, using `report.md` -verbatim. The fork comment is therefore the same comment the action posts on a non-fork -PR, and a later run with `pr-comments: true` finds and edits it instead of adding a -second one. The artifact contains only `report.md`, `result.json` and `pr-number` — no -code or secrets. +So a contributor pushing to a fork already gets the red check, the per-finding annotations +on their diff, and the whole report in the job summary. The action says so in the log: ``` - pull_request workflow_run - │ │ - ▼ ▼ -┌──────────────┐ ┌──────────────────┐ -│ Workflow A │ │ Workflow B │ -│ (checks) │────►│ (comment writer) │ -│ │ │ │ -│ Token: READ │ │ Token: WRITE │ -│ Saves report │ │ Downloads it │ -│ 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 - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: commit-check/commit-check-action@v2 - id: commit-check - with: - message: true - branch: true - pr-comments: false # comments handled by Workflow B - job-summary: true - - # The action exits 1 on a failure, so both steps below need `always()` - # or the artifact is missing on exactly the runs that need a comment. - # The outputs are written before the action exits, so they are present - # on failing runs too. - - name: Save the report for Workflow B - if: always() && steps.commit-check.outputs.report != '' - env: - REPORT: ${{ steps.commit-check.outputs.report }} - RESULT: ${{ steps.commit-check.outputs.result }} - PR_NUMBER: ${{ github.event.number }} - run: | - mkdir -p commit-check-result - printf '%s' "$REPORT" > commit-check-result/report.md - printf '%s\n' "$RESULT" > commit-check-result/result.json - printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - - uses: actions/upload-artifact@v4 - if: always() && steps.commit-check.outputs.report != '' - with: - name: commit-check-result - path: commit-check-result/ +::warning::Skipping PR comment: pull requests from forked repositories cannot write +comments via the pull_request event (GITHUB_TOKEN is read-only for forks). The findings +are in this job's summary and in the annotations on the Files changed tab. ``` -> 📄 Full file: [`examples/commit-check-workflow-a.yml`](../examples/commit-check-workflow-a.yml) +The run is **not** failed by this: `pr-comments: true` on a fork PR is a no-op, not an error. -### Workflow B +## If you want feedback on the pull request itself -`.github/workflows/commit-check-comment.yml` (triggered by `workflow_run`): +### Install the Commit Check GitHub App (recommended) -```yaml -name: Commit Check Comment +The [Commit Check GitHub App](https://github.com/marketplace/commit-check) is not bound by +the `pull_request` token at all: it receives the `pull_request` webhook on the **base** +repository and acts with its own installation token. Fork pull requests are ordinary +pull requests to it. -on: - workflow_run: - workflows: ["Commit Check"] # must match Workflow A's name exactly - types: [completed] +- **No workflow file.** Install it on the repository and it runs. +- **One check run per commit**, with the failing value, the rule and the suggested fix. + A check run is a first-class PR surface: it shows in the merge box and links straight + to the details. +- **Free on public repositories and personal accounts** — which is where fork pull + requests happen. Private organization repositories need the Team plan. -jobs: - comment: - runs-on: ubuntu-latest - permissions: - pull-requests: write - actions: read # needed to download another run's artifact - steps: - # Download by run id: the PR number travels inside the artifact because - # github.event.workflow_run.pull_requests is empty for fork PRs. - - uses: actions/download-artifact@v4 - id: download - continue-on-error: true # no artifact when A's install failed: nothing to post - with: - name: commit-check-result - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - name: Post or update the PR comment - if: steps.download.outcome == 'success' - uses: actions/github-script@v7 - with: - script: | - // See examples/commit-check-workflow-b.yml for the full script - const fs = require('fs'); - const prNumber = Number(fs.readFileSync('pr-number', 'utf8').trim()); - const body = fs.readFileSync('report.md', 'utf8'); // posted verbatim - const MARKER = ''; - // Finds the comment that starts with MARKER and updates it, - // or creates one when there is none yet -``` - -> 📄 Full file: [`examples/commit-check-workflow-b.yml`](../examples/commit-check-workflow-b.yml) - -### Key security benefits +It reports as a check run, not as a comment. If your goal is "the contributor sees what +failed, on the pull request, without me writing YAML", this is the shortest path. -- 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 `report.md`, `result.json` and `pr-number` — no code or secrets +You can run the App and this action together: the App covers fork pull requests, the +action gives you enforcement you control in CI, per-rule outputs for later steps, and +`CCHK_*` overrides. ---- +### Or run on `pull_request_target` -## 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. +If you cannot install a GitHub App — GitHub Enterprise Server, or an organization policy +that forbids it — `pull_request_target` runs in the context of the base repository, so +`GITHUB_TOKEN` has the permissions your workflow asks for and `pr-comments: true` works +on fork pull requests. ```yaml -name: Commit Check - on: pull_request_target: - branches: ["main"] + +permissions: + contents: read + pull-requests: write 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@v7 with: + ref: refs/pull/${{ github.event.number }}/merge # the PR's commits fetch-depth: 0 - uses: commit-check/commit-check-action@v2 with: @@ -192,9 +80,18 @@ 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. +> [!WARNING] +> `pull_request_target` grants a writable token to a workflow whose checkout contains the +> fork's code. commit-check only *reads* commit metadata and never executes the checked-out +> tree, but any other step you add to this job runs with that token. Keep the job to the +> checkout and this action, never cache or build from it, and never expose secrets to it. +> See [GitHub's guidance on `pull_request_target`](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/). + +## What this page used to describe + +Earlier versions documented a two-workflow pattern: workflow A runs the checks on +`pull_request` and uploads an artifact, workflow B picks it up on `workflow_run` and posts +the comment with a writable token. It worked, but it cost two workflow files, an artifact +round trip, `actions: read`, and smuggling the PR number through the artifact — all to move +information the contributor could already see into a comment. The App does the same job with +no YAML at all, so the pattern and its example workflows have been removed. diff --git a/examples/commit-check-workflow-a.yml b/examples/commit-check-workflow-a.yml deleted file mode 100644 index 920bce0..0000000 --- a/examples/commit-check-workflow-a.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Workflow A: Run commit checks on pull_request events. -# -# This workflow is triggered by pull_request and runs the checks. On a pull -# request from a fork its GITHUB_TOKEN is read-only, so it cannot comment; -# instead it saves the action's `report` and `result` outputs as an artifact -# for Workflow B (commit-check-comment.yml), which runs with write permissions -# in the base repository and posts the report as the PR comment. -# -# See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md - -name: Commit Check - -on: - pull_request: - branches: ["main"] - -jobs: - check: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: commit-check/commit-check-action@v2 - id: commit-check - with: - message: true - branch: true - pr-comments: false # comments handled by Workflow B - job-summary: true - - # The action exits 1 on a failure, so both steps below need `always()` - # or the artifact is missing on exactly the runs that need a comment. - # The outputs are written before the action exits, so they are present - # on failing runs too; the second condition only skips the upload when - # the action itself could not run. - - name: Save the report for Workflow B - if: always() && steps.commit-check.outputs.report != '' - env: - REPORT: ${{ steps.commit-check.outputs.report }} - RESULT: ${{ steps.commit-check.outputs.result }} - PR_NUMBER: ${{ github.event.number }} - run: | - mkdir -p commit-check-result - printf '%s' "$REPORT" > commit-check-result/report.md - printf '%s\n' "$RESULT" > commit-check-result/result.json - printf '%s\n' "$PR_NUMBER" > commit-check-result/pr-number - - uses: actions/upload-artifact@v4 - if: always() && steps.commit-check.outputs.report != '' - with: - name: commit-check-result - path: commit-check-result/ diff --git a/examples/commit-check-workflow-b.yml b/examples/commit-check-workflow-b.yml deleted file mode 100644 index f14569a..0000000 --- a/examples/commit-check-workflow-b.yml +++ /dev/null @@ -1,82 +0,0 @@ -# Workflow B: Post the PR comment after Workflow A completes. -# -# This workflow is triggered by the workflow_run event from Workflow A. It -# runs in the base repository's context with the permissions granted below, -# which makes it safe for fork PRs: it never checks out the fork's code, it -# only downloads the artifact Workflow A saved and posts report.md verbatim. -# -# Prerequisites: -# - Workflow A (commit-check.yml) must exist, be named "Commit Check", and -# upload an artifact named commit-check-result containing report.md and -# pr-number (see commit-check-workflow-a.yml). -# -# See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md - -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 another run's artifact - steps: - # Download by run id: the PR number travels inside the artifact because - # github.event.workflow_run.pull_requests is empty for fork PRs. The - # artifact is missing when Workflow A never got as far as running the - # checks (its install step failed); that is A's failure to show, not B's. - - uses: actions/download-artifact@v4 - id: download - continue-on-error: true - with: - name: commit-check-result - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - if: steps.download.outcome != 'success' - run: echo "::notice::Workflow A (run ${{ github.event.workflow_run.id }}) uploaded no commit-check-result artifact; nothing to post." - - - name: Post or update the PR comment - if: steps.download.outcome == 'success' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const prNumber = Number(fs.readFileSync('pr-number', 'utf8').trim()); - if (!Number.isInteger(prNumber) || prNumber <= 0) { - core.setFailed(`Artifact carries no pull request number (got ${JSON.stringify(prNumber)})`); - return; - } - - // report.md is the action's `report` output: the same Markdown the - // action posts itself, opening with the marker it uses to find its - // own comment. Posting it unchanged means a later run with - // `pr-comments: true` edits this comment instead of adding one. - const body = fs.readFileSync('report.md', 'utf8'); - const MARKER = ''; - - const comments = await github.paginate(github.rest.issues.listComments, { - ...context.repo, - issue_number: prNumber, - per_page: 100, - }); - const existing = comments.find(c => (c.body ?? '').startsWith(MARKER)); - - 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, - }); - } diff --git a/main.py b/main.py index 58b4fed..23dd4b0 100755 --- a/main.py +++ b/main.py @@ -9,9 +9,7 @@ * **job summary** — a Markdown policy report table * **PR comment** — a compact Markdown summary (idempotently updated) -and exposes two action outputs: ``result`` (the check data as JSON) and -``report`` (the rendered Markdown, for workflows that post the comment -themselves). +and exposes the check data as JSON in the ``result`` action output. """ import json @@ -1240,10 +1238,6 @@ def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: # ``fix: handle `None` \| retry`` rather than `fix: handle `None` | retry`. # - The step log renders the same tree (_render_scopes); it adds the docs URL, # which the Markdown report already carries on the rule ID in the table. -# - The whole report, marker to footer, is also written verbatim to the -# `report` action output beside the JSON `result`, so a workflow_run job can -# post it for a fork pull request and produce the same comment this action -# would have posted itself. # --------------------------------------------------------------------------- @@ -1344,20 +1338,11 @@ def add_job_summary(results: list[ScopeResult]) -> int: def set_result_output(results: list[ScopeResult]) -> None: - """Expose the results as the ``result`` and ``report`` action outputs. - - ``result`` is the structured JSON downstream steps gate on. ``report`` is - the rendered Markdown — the very text the job summary and the PR comment - show — for the one caller that cannot post it itself: a ``pull_request`` - run on a fork has a read-only token, so it hands the report to a - ``workflow_run`` job that posts it verbatim (docs/fork-pr-comments.md). - Shipping the text rather than making that job re-render the JSON keeps - the fork comment identical to every other one, marker and footer - included, so a later run with ``pr-comments: true`` adopts it. - - Both values are written through :func:`_write_output`, whose per-write - random delimiter keeps a line of user text (a commit body line reading - ``EOF``, say) from closing the heredoc early. + """Expose the structured results as the ``result`` action output. + + Written through :func:`_write_output`, whose per-write random delimiter + keeps a line of user text (a commit subject reading ``EOF``, say) from + closing the heredoc early. """ output_path = os.getenv("GITHUB_OUTPUT") if not output_path: @@ -1376,7 +1361,6 @@ def set_result_output(results: list[ScopeResult]) -> None: } with open(output_path, "a", encoding="utf-8") as f: _write_output(f, "result", json.dumps(payload, indent=2)) - _write_output(f, "report", render_report(results)) def _write_output(f: Any, name: str, value: str) -> None: @@ -1385,9 +1369,9 @@ def _write_output(f: Any, name: str, value: str) -> None: The runner reads lines up to the first one equal to the delimiter and rejects the whole file if it never finds one, which fails the step and drops every output written after the bad one. A fixed ``EOF`` delimiter - is therefore unsafe for the report: on a failing rule it quotes the - commit message in full, and a body line reading ``EOF`` is legal. So the - delimiter is random per write, the shape actions/github-script uses. + is therefore unsafe: the JSON quotes commit subjects and error text as + they are, and a subject reading ``EOF`` is legal. So the delimiter is + random per write, the shape actions/github-script uses. """ delimiter = f"ghadelimiter_{uuid.uuid4()}" while delimiter in value: # pragma: no cover - 122 random bits @@ -1496,9 +1480,9 @@ def add_pr_comments(results: list[ScopeResult]) -> int: msg = ( "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/blob/main/docs/fork-pr-comments.md " - "for how to enable PR comments on fork PRs." + "read-only for forks). The findings are in this job's summary and " + "in the annotations on the Files changed tab. " + "See https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md" ) print(f"::warning::{msg}") if JOB_SUMMARY_ENABLED and GITHUB_STEP_SUMMARY: @@ -1508,9 +1492,10 @@ def add_pr_comments(results: list[ScopeResult]) -> int: "### \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]" + "read-only permissions. The report above and the annotations " + "on the Files changed tab are unaffected.\n\n" + "> **\U0001f4a1 Tip:** see " + "[Fork pull requests]" "(https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md).\n" ) return 0 diff --git a/main_test.py b/main_test.py index e3e28dd..a5a106e 100644 --- a/main_test.py +++ b/main_test.py @@ -1777,20 +1777,19 @@ def test_writes_heredoc_json(self): with open(output_path, encoding="utf-8") as file_obj: content = file_obj.read() self.assertRegex(content, r"(?m)^result<