Merge pull request #636 from guarzo/worktree-ci-fork-pr-comment

ci: post PR test comment via workflow_run to fix fork PR failures
This commit is contained in:
Dmitry Popov
2026-08-10 23:34:56 +02:00
committed by GitHub
2 changed files with 235 additions and 79 deletions
+119
View File
@@ -0,0 +1,119 @@
name: 💬 PR Test Comment
# Posts the test-results comment produced by the "🧪 Test Suite" workflow.
#
# Why this is a separate workflow: for pull_request events from forks GitHub
# issues a read-only GITHUB_TOKEN regardless of the `permissions:` block, so the
# test workflow itself cannot post a comment (403 "Resource not accessible by
# integration"). A workflow_run workflow runs in the base repository's context
# with a writable token, so it can.
#
# SECURITY: this workflow runs with write permissions and must never execute
# code from the pull request. Do not add actions/checkout of the PR head, and do
# not run build/test tooling here. The downloaded artifact is untrusted input:
# the comment body is passed to the API via `body-path` (never interpolated into
# a run: block or a ${{ }} expression) and the PR number is re-validated as
# numeric before use.
#
# A numeric PR number is not necessarily the *right* PR number: the artifact is
# produced by a job that has already run fork-authored code, so it could name
# any issue or PR in the base repository. The "Verify PR matches triggering run"
# step below closes that by requiring the claimed PR's head SHA to equal the
# head SHA of the run that triggered this workflow.
on:
workflow_run:
# Must match the `name:` of .github/workflows/test.yml exactly.
workflows: ['🧪 Test Suite']
types: [completed]
permissions:
contents: read
# `actions: read` is required: download-artifact with `run-id:` reads the
# artifacts of a *different* workflow run, which the default token scope does
# not cover. Without it the download 403s and the workflow silently posts
# nothing.
actions: read
# Read-only PR access is only used to verify the artifact's PR number against
# the triggering run's head SHA. The comment itself is an *issue* comment, so
# `issues: write` is the scope that grants posting.
pull-requests: read
issues: write
jobs:
comment:
name: Post test results comment
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download comment artifact
id: download
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-comment
path: pr-comment
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Read PR number
id: pr
if: steps.download.outcome == 'success'
run: |
if [ ! -f pr-comment/pr-number ] || [ ! -f pr-comment/body.md ]; then
echo "Artifact incomplete; nothing to post."
echo "valid=false" >> "$GITHUB_OUTPUT"
exit 0
fi
number=$(tr -dc '0-9' < pr-comment/pr-number | head -c 10)
if [ -z "$number" ]; then
echo "PR number is not numeric; refusing to continue."
echo "valid=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
echo "valid=true" >> "$GITHUB_OUTPUT"
- name: Verify PR matches triggering run
if: steps.pr.outputs.valid == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
# The artifact comes from a job that ran untrusted code, so the PR
# number it claims is only a hint. Accept it only if that PR's head
# commit is the exact commit the triggering run tested; otherwise a
# fork could steer this privileged comment at any PR or issue.
actual_sha=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq .head.sha)
if [ "$actual_sha" != "$EXPECTED_SHA" ]; then
echo "PR #$PR_NUMBER head is $actual_sha but the triggering run tested $EXPECTED_SHA." >&2
echo "Refusing to comment on a PR the artifact does not belong to." >&2
exit 1
fi
echo "PR #$PR_NUMBER head matches the triggering run ($EXPECTED_SHA)."
- name: Find existing PR comment
id: find_comment
if: steps.pr.outputs.valid == 'true'
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ steps.pr.outputs.number }}
comment-author: 'github-actions[bot]'
body-includes: '## 🧪 Test Results Summary'
- name: Create or update PR comment
if: steps.pr.outputs.valid == 'true'
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find_comment.outputs.comment-id }}
issue-number: ${{ steps.pr.outputs.number }}
edit-mode: replace
body-path: pr-comment/body.md
+116 -79
View File
@@ -6,10 +6,11 @@ on:
push:
branches: [main, develop]
# This workflow executes fork-authored code (mix deps.get / compile / test), so
# it holds no write scopes. The test-results comment is posted by the privileged
# pr-comment.yml workflow via workflow_run.
permissions:
contents: read
pull-requests: write
issues: write
env:
MIX_ENV: test
@@ -253,81 +254,117 @@ jobs:
fi
continue-on-error: true
- name: Find existing PR comment
if: github.event_name == 'pull_request'
id: find_comment
uses: peter-evans/find-comment@v3
# Fork PRs get a read-only GITHUB_TOKEN, so this workflow cannot post the
# comment itself. Instead the rendered body is uploaded as an artifact and
# posted by the privileged `pr-comment.yml` workflow via workflow_run.
- name: Render PR comment body
if: always() && github.event_name == 'pull_request'
env:
OVERALL_SCORE: ${{ steps.summary.outputs.overall_score }}
OVERALL_STATUS: ${{ steps.summary.outputs.overall_status }}
FORMAT_STATUS: ${{ steps.format.outputs.status }}
FORMAT_COUNT: ${{ steps.format.outputs.count }}
COMPILE_STATUS: ${{ steps.compile.outputs.status }}
COMPILE_WARNINGS: ${{ steps.compile.outputs.warnings }}
TESTS_STATUS: ${{ steps.tests.outputs.status }}
TESTS_FAILURES: ${{ steps.tests.outputs.failures }}
TESTS_TOTAL: ${{ steps.tests.outputs.total }}
TESTS_SUCCESS_RATE: ${{ steps.tests.outputs.success_rate }}
COVERAGE_STATUS: ${{ steps.coverage.outputs.status }}
COVERAGE_PERCENTAGE: ${{ steps.coverage.outputs.percentage }}
CREDO_STATUS: ${{ steps.credo.outputs.status }}
CREDO_TOTAL: ${{ steps.credo.outputs.total_issues }}
CREDO_HIGH: ${{ steps.credo.outputs.high_issues }}
CREDO_NORMAL: ${{ steps.credo.outputs.normal_issues }}
CREDO_LOW: ${{ steps.credo.outputs.low_issues }}
DIALYZER_STATUS: ${{ steps.dialyzer.outputs.status }}
DIALYZER_ERRORS: ${{ steps.dialyzer.outputs.errors }}
DIALYZER_WARNINGS: ${{ steps.dialyzer.outputs.warnings }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p pr-comment
# Validate the PR number so the trusted workflow can trust this file.
case "$PR_NUMBER" in
''|*[!0-9]*) echo "Refusing to emit non-numeric PR number" >&2; exit 1 ;;
esac
printf '%s\n' "$PR_NUMBER" > pr-comment/pr-number
# Values are interpolated by the shell from env, never by Actions
# expression substitution, so tool output cannot inject workflow syntax.
cat > pr-comment/body.md <<EOF
## 🧪 Test Results Summary
**Overall Quality Score: ${OVERALL_SCORE}%** ${OVERALL_STATUS}
### 📊 Metrics Dashboard
| Category | Status | Count | Details |
|----------|---------|-------|---------|
| 📝 **Code Formatting** | ${FORMAT_STATUS} | ${FORMAT_COUNT} issues | \`mix format --check-formatted\` |
| 🔨 **Compilation** | ${COMPILE_STATUS} | ${COMPILE_WARNINGS} warnings | \`mix compile\` |
| 🧪 **Tests** | ${TESTS_STATUS} | ${TESTS_FAILURES}/${TESTS_TOTAL} failed | Success rate: ${TESTS_SUCCESS_RATE}% |
| 📊 **Coverage** | ${COVERAGE_STATUS} | ${COVERAGE_PERCENTAGE}% | \`mix coveralls\` |
| 🎯 **Credo** | ${CREDO_STATUS} | ${CREDO_TOTAL} issues | High: ${CREDO_HIGH}, Normal: ${CREDO_NORMAL}, Low: ${CREDO_LOW} |
| 🔍 **Dialyzer** | ${DIALYZER_STATUS} | ${DIALYZER_ERRORS} errors, ${DIALYZER_WARNINGS} warnings | \`mix dialyzer\` |
### 🎯 Quality Gates
Based on the project's quality thresholds:
- **Compilation Warnings**: ${COMPILE_WARNINGS}/148 (limit: 148)
- **Credo Issues**: ${CREDO_TOTAL}/87 (limit: 87)
- **Dialyzer Warnings**: ${DIALYZER_WARNINGS}/161 (limit: 161)
- **Test Coverage**: ${COVERAGE_PERCENTAGE}%/50% (minimum: 50%)
- **Test Failures**: ${TESTS_FAILURES}/0 (limit: 0)
<details>
<summary>📈 Progress Toward Goals</summary>
Target goals for the project:
- ✨ **Zero compilation warnings** (currently: ${COMPILE_WARNINGS})
- ✨ **≤10 Credo issues** (currently: ${CREDO_TOTAL})
- ✨ **Zero Dialyzer warnings** (currently: ${DIALYZER_WARNINGS})
- ✨ **≥85% test coverage** (currently: ${COVERAGE_PERCENTAGE}%)
- ✅ **Zero test failures** (currently: ${TESTS_FAILURES})
</details>
<details>
<summary>🔧 Quick Actions</summary>
To improve code quality:
\`\`\`bash
# Fix formatting issues
mix format
# View detailed Credo analysis
mix credo --strict
# Check Dialyzer warnings
mix dialyzer
# Generate detailed coverage report
mix coveralls.html
\`\`\`
</details>
---
🤖 *Auto-generated by GitHub Actions* • Updated: $(date -u '+%Y-%m-%d %H:%M UTC')
> **Note**: This comment will be updated automatically when new commits are pushed to this PR.
EOF
# Mirror to the run summary so the report is visible even if the
# follow-up commenting workflow is unavailable.
cat pr-comment/body.md >> "$GITHUB_STEP_SUMMARY"
continue-on-error: true
- name: Upload PR comment body
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '## 🧪 Test Results Summary'
- name: Create or update PR comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find_comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
edit-mode: replace
body: |
## 🧪 Test Results Summary
**Overall Quality Score: ${{ steps.summary.outputs.overall_score }}%** ${{ steps.summary.outputs.overall_status }}
### 📊 Metrics Dashboard
| Category | Status | Count | Details |
|----------|---------|-------|---------|
| 📝 **Code Formatting** | ${{ steps.format.outputs.status }} | ${{ steps.format.outputs.count }} issues | `mix format --check-formatted` |
| 🔨 **Compilation** | ${{ steps.compile.outputs.status }} | ${{ steps.compile.outputs.warnings }} warnings | `mix compile` |
| 🧪 **Tests** | ${{ steps.tests.outputs.status }} | ${{ steps.tests.outputs.failures }}/${{ steps.tests.outputs.total }} failed | Success rate: ${{ steps.tests.outputs.success_rate }}% |
| 📊 **Coverage** | ${{ steps.coverage.outputs.status }} | ${{ steps.coverage.outputs.percentage }}% | `mix coveralls` |
| 🎯 **Credo** | ${{ steps.credo.outputs.status }} | ${{ steps.credo.outputs.total_issues }} issues | High: ${{ steps.credo.outputs.high_issues }}, Normal: ${{ steps.credo.outputs.normal_issues }}, Low: ${{ steps.credo.outputs.low_issues }} |
| 🔍 **Dialyzer** | ${{ steps.dialyzer.outputs.status }} | ${{ steps.dialyzer.outputs.errors }} errors, ${{ steps.dialyzer.outputs.warnings }} warnings | `mix dialyzer` |
### 🎯 Quality Gates
Based on the project's quality thresholds:
- **Compilation Warnings**: ${{ steps.compile.outputs.warnings }}/148 (limit: 148)
- **Credo Issues**: ${{ steps.credo.outputs.total_issues }}/87 (limit: 87)
- **Dialyzer Warnings**: ${{ steps.dialyzer.outputs.warnings }}/161 (limit: 161)
- **Test Coverage**: ${{ steps.coverage.outputs.percentage }}%/50% (minimum: 50%)
- **Test Failures**: ${{ steps.tests.outputs.failures }}/0 (limit: 0)
<details>
<summary>📈 Progress Toward Goals</summary>
Target goals for the project:
- ✨ **Zero compilation warnings** (currently: ${{ steps.compile.outputs.warnings }})
- ✨ **≤10 Credo issues** (currently: ${{ steps.credo.outputs.total_issues }})
- ✨ **Zero Dialyzer warnings** (currently: ${{ steps.dialyzer.outputs.warnings }})
- ✨ **≥85% test coverage** (currently: ${{ steps.coverage.outputs.percentage }}%)
- ✅ **Zero test failures** (currently: ${{ steps.tests.outputs.failures }})
</details>
<details>
<summary>🔧 Quick Actions</summary>
To improve code quality:
```bash
# Fix formatting issues
mix format
# View detailed Credo analysis
mix credo --strict
# Check Dialyzer warnings
mix dialyzer
# Generate detailed coverage report
mix coveralls.html
```
</details>
---
🤖 *Auto-generated by GitHub Actions* • Updated: ${{ github.event.head_commit.timestamp }}
> **Note**: This comment will be updated automatically when new commits are pushed to this PR.
name: pr-comment
path: pr-comment/
retention-days: 1
continue-on-error: true