Skip to content

Commit 2335750

Browse files
authored
ci: fix commitlint workflow and post lint failures as PR comments (#735)
1 parent c870b8c commit 2335750

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

.gitallowed

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
name: Commit Lint Comment
2+
3+
# Privileged follow-up to commitlint.yaml. Triggered by completion of the
4+
# unprivileged Commit Lint workflow; downloads the lint output artifact and
5+
# posts it as a PR comment. This split is the recommended pattern for safely
6+
# performing privileged operations in response to fork PRs:
7+
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
8+
#
9+
# Security: this workflow does not check out PR code. The PR number is
10+
# resolved from the trusted workflow_run event payload (not the artifact),
11+
# and the commitlint text output is rendered inside a dynamically sized
12+
# code fence so embedded backticks cannot escape it.
13+
on:
14+
workflow_run:
15+
workflows: ["Commit Lint"]
16+
types:
17+
- completed
18+
19+
permissions:
20+
contents: read
21+
pull-requests: write
22+
actions: read
23+
24+
jobs:
25+
comment:
26+
name: Comment on PR
27+
if: >
28+
github.event.workflow_run.event == 'pull_request' &&
29+
github.event.workflow_run.conclusion == 'failure'
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Download lint artifact
33+
id: download
34+
uses: actions/download-artifact@v4
35+
with:
36+
name: commitlint-failure
37+
path: lint-artifact
38+
run-id: ${{ github.event.workflow_run.id }}
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
continue-on-error: true
41+
42+
- name: Skip if no artifact
43+
if: steps.download.outcome != 'success'
44+
run: |
45+
echo "No commitlint-failure artifact uploaded by the Commit Lint run; nothing to comment."
46+
47+
- name: Resolve PR number from workflow_run
48+
if: steps.download.outcome == 'success'
49+
id: pr
50+
env:
51+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
53+
REPO: ${{ github.repository }}
54+
shell: bash
55+
run: |
56+
# Derive PR number from trusted GitHub event data, not the artifact.
57+
# The artifact is produced by an unprivileged pull_request workflow
58+
# whose workflow file PR authors control on fork PRs, so any value
59+
# they write into the artifact could be used to spoof comments onto
60+
# a different PR. head_sha on the workflow_run event is GitHub-set
61+
# and trustworthy.
62+
pr=$(gh api "repos/$REPO/commits/$HEAD_SHA/pulls" \
63+
--jq '.[] | select(.state=="open") | .number' | head -n1)
64+
if ! [[ "$pr" =~ ^[0-9]+$ ]]; then
65+
echo "No open PR found for head SHA $HEAD_SHA; nothing to comment."
66+
exit 0
67+
fi
68+
echo "pr_number=$pr" >> "$GITHUB_OUTPUT"
69+
70+
- name: Post comment
71+
if: steps.download.outcome == 'success' && steps.pr.outputs.pr_number != ''
72+
env:
73+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74+
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
75+
REPO: ${{ github.repository }}
76+
shell: bash
77+
run: |
78+
# The artifact contents are untrusted PR-author-controlled commit
79+
# text. Pick a code-fence length that is at least one backtick
80+
# longer than the longest backtick run in the file, so the author
81+
# cannot escape the fence by embedding ``` in a commit message.
82+
max_ticks=$(grep -oE '`+' lint-artifact/commitlint-output.txt 2>/dev/null \
83+
| awk '{ if (length > m) m = length } END { print m+0 }')
84+
fence_len=$((max_ticks + 1))
85+
[ "$fence_len" -lt 3 ] && fence_len=3
86+
fence=$(printf '`%.0s' $(seq 1 "$fence_len"))
87+
{
88+
echo "## Commit message lint failed"
89+
echo ""
90+
echo "One or more commit messages don't follow the [Conventional Commits](https://www.conventionalcommits.org) format required by this project."
91+
echo ""
92+
echo "<details><summary>commitlint output</summary>"
93+
echo ""
94+
echo "$fence"
95+
cat lint-artifact/commitlint-output.txt
96+
echo "$fence"
97+
echo ""
98+
echo "</details>"
99+
echo ""
100+
echo "**Required format:** \`<type>: <description>\` (max 120 chars for the header)"
101+
echo ""
102+
echo "**Allowed types:** \`feat\`, \`fix\`, \`docs\`, \`example\`, \`examples\`, \`chore\`, \`refactor\`, \`perf\`, \`test\`, \`ci\`, \`revert\`"
103+
echo ""
104+
echo "To fix the most recent commit: \`git commit --amend\`"
105+
echo ""
106+
echo "To reword older commits: \`git rebase -i origin/main\`"
107+
} > comment.md
108+
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file comment.md

.github/workflows/commitlint.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,32 @@ jobs:
1717
uses: actions/checkout@v4
1818
with:
1919
fetch-depth: 0
20+
persist-credentials: false
2021

2122
- name: Setup Node.js
2223
uses: actions/setup-node@v4
2324
with:
2425
node-version: 20
2526

2627
- name: Install commitlint
27-
run: npm install --save-dev @commitlint/{cli,config-conventional}
28+
run: npm install --ignore-scripts --save-dev @commitlint/{cli,config-conventional}
2829

2930
- name: Lint commits
3031
id: lint
32+
shell: bash
3133
run: |
34+
set -o pipefail
3235
npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose 2>&1 | tee commitlint-output.txt
3336
continue-on-error: true
3437

38+
- name: Upload lint output
39+
if: steps.lint.outcome == 'failure'
40+
uses: actions/upload-artifact@v4
41+
with:
42+
name: commitlint-failure
43+
path: commitlint-output.txt
44+
retention-days: 1
45+
3546
- name: Show help on failure
3647
if: steps.lint.outcome == 'failure'
3748
run: |

0 commit comments

Comments
 (0)