diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..ab30f06 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,102 @@ +# CI Workflows 说明 + +本项目使用两步 workflow 实现跨仓库 PR 的评论功能。 + +## Workflows + +### 1. PR Check (`pr-check.yml`) + +**触发条件**: +- `pull_request` 事件 +- `workflow_dispatch` 手动触发 + +**功能**: +- 在 4 个平台上运行代码检查(ubuntu-22.04, windows-latest, macos-14, macos-15) +- 执行 `npm run check` → `npm run check:fix` → `npm run check`(复验) +- 将每个平台的状态保存到 artifact (`pr-check-state-*`) +- 上传日志文件到 artifact (`pr-check-*`) + +**输出 artifacts**: +- `pr-check-`: 包含 check.log, check-fix.log, check-recheck.log +- `pr-check-state-`: 包含平台状态的 JSON 文件 + +### 2. PR Check Comment (`pr-check-comment.yml`) + +**触发条件**: +- `workflow_run` 事件(当 PR Check 完成时) +- 仅在默认分支(main)上的 workflow 文件会被触发 + +**功能**: +- 下载所有平台的状态 artifact +- 聚合所有平台的检查结果 +- 创建/更新 PR 评论,显示所有平台的状态 + +**权限**: +- 使用主仓库的 GITHUB_TOKEN(有完整的 `pull-requests: write` 权限) +- 支持跨仓库 PR(fork)的评论 + +## 为什么需要两步 workflow? + +### 问题 +在 fork 仓库发起的 PR 中,`GITHUB_TOKEN` 只有 `read` 权限,无法创建/更新评论。这是 GitHub 的安全限制。 + +### 解决方案 +使用 `workflow_run` 事件: +1. PR 触发的 workflow 在 fork 的上下文中运行(权限受限) +2. `workflow_run` 触发的 workflow 在主仓库的上下文中运行(权限完整) +3. 通过 artifact 传递数据,实现权限隔离 + +### 架构图 +``` +PR 提交 + ↓ +PR Check (fork 上下文,read-only) + ├─ 运行检查 + ├─ 保存状态到 artifact + └─ 上传日志 + ↓ +PR Check 完成 + ↓ +PR Check Comment (main 上下文,write 权限) ← workflow_run 触发 + ├─ 下载 artifacts + ├─ 聚合状态 + └─ 发布/更新评论 ✅ +``` + +## 重要限制 + +⚠️ **workflow_run 要求**: +- 被触发的 workflow 文件必须存在于**默认分支**(main) +- 修改 `pr-check-comment.yml` 后,必须先合并到 main 才能生效 +- Fork PR 无法测试评论功能,只能在合并到 main 后验证 + +## 开发建议 + +### 修改 PR Check workflow +1. 修改 `.github/workflows/pr-check.yml` +2. 提交到功能分支并创建 PR +3. PR 中可以直接测试检查逻辑 +4. 合并到 main + +### 修改 PR Check Comment workflow +1. 修改 `.github/workflows/pr-check-comment.yml` +2. 提交并合并到 main(评论功能无法在 PR 中测试) +3. 合并后,下一个 PR 会触发新版本的评论 workflow + +### 调试评论功能 +```bash +# 查看评论 workflow 运行记录 +gh run list --workflow="PR Check Comment" --limit 5 + +# 查看特定运行的日志 +gh run view --log + +# 查看 PR Check 的 artifacts +gh api repos/OWNER/REPO/actions/runs//artifacts +``` + +## 参考资料 + +- [GitHub Actions: workflow_run event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run) +- [GitHub Actions: Permissions for GITHUB_TOKEN](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) +- [Using artifacts to share data between jobs](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) diff --git a/.github/workflows/pr-check-comment.yml b/.github/workflows/pr-check-comment.yml new file mode 100644 index 0000000..ba7ca25 --- /dev/null +++ b/.github/workflows/pr-check-comment.yml @@ -0,0 +1,223 @@ +name: PR Check Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: + - completed + +permissions: + pull-requests: write + +jobs: + comment: + name: Update PR Comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download artifacts + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // 获取所有 artifacts + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + console.log(`Found ${artifacts.data.artifacts.length} artifacts`); + + // 下载所有平台的状态文件 + for (const artifact of artifacts.data.artifacts) { + if (artifact.name.startsWith('pr-check-state-')) { + console.log(`Downloading artifact: ${artifact.name}`); + + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id, + archive_format: 'zip', + }); + + fs.writeFileSync(`${artifact.name}.zip`, Buffer.from(download.data)); + } + } + + - name: Extract artifacts + run: | + mkdir -p states + for zip in pr-check-state-*.zip; do + if [ -f "$zip" ]; then + unzip -o "$zip" -d states/ + fi + done + ls -la states/ || true + + - name: Update PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const marker = ''; + const stateMarker = '` + ].join('\n'); + + // 查找现有评论 + const { data: comments } = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + const existing = comments.find((c) => c.body.includes(marker)); + + if (existing) { + console.log(`Updating existing comment #${existing.id}`); + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } else { + console.log('Creating new comment'); + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index ff11352..54e73a1 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -23,7 +23,7 @@ jobs: - name: macos-arm64 os: macos-14 - name: macos-x64 - os: macos-13 + os: macos-15 steps: - name: Checkout repository @@ -133,175 +133,29 @@ jobs: check-fix.log check-recheck.log - - name: Update PR comment - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - continue-on-error: true - uses: actions/github-script@v7 - env: - MATRIX_NAME: ${{ matrix.name }} - RUN_ID: ${{ github.run_id }} - JOB_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - CHECK_OUTCOME: ${{ steps.check.outcome }} - FIX_OUTCOME: ${{ steps.check_fix.outcome }} - RECHECK_OUTCOME: ${{ steps.recheck.outcome }} - ARTIFACT_NAME: pr-check-${{ matrix.name }} - with: - script: | - const marker = ''; - const stateMarker = '/s); - if (match) { - try { - state = { ...state, ...JSON.parse(match[1]) }; - } catch {} - } - } - state[newEntry.platform] = newEntry; - - const tableZh = renderTable(statusLabelZh, { - platform: '平台', - status: '结果', - detail: '明细', - artifact: '日志包', - link: '运行链接' - }); - const tableEn = renderTable(statusLabelEn, { - platform: 'Platform', - status: 'Status', - detail: 'Detail', - artifact: 'Artifact', - link: 'Run' - }); - - const body = [ - '本评论会随各平台任务完成自动更新:', - '如首轮 `npm run check` 失败,请在本地执行 `npm run check:fix` → `npm run check` 并提交修复 commit。', - '如 fix 仍失败,请在本地排查并确保 `npm run check` 通过后再提交。', - '跨平台差异若无法复现,请复制日志交给 AI 获取排查建议。', - '', - tableZh, - '', - '---', - '', - 'This comment auto-updates as each platform finishes:', - 'If the first `npm run check` fails, run locally: `npm run check:fix` → `npm run check` and commit the fix.', - 'If fix still fails, investigate locally and ensure `npm run check` passes before committing.', - 'If cross-platform issues can’t be reproduced, copy logs to an AI for hints.', - '', - tableEn, - marker, - `${stateMarker}${JSON.stringify(state)} -->` - ].join('\n'); + - name: Save state for comment + if: always() + shell: bash + run: | + cat > state.json <<'EOF' + { + "platform": "${{ matrix.name }}", + "check": "${{ steps.check.outcome }}", + "fix": "${{ steps.check_fix.outcome }}", + "recheck": "${{ steps.recheck.outcome }}", + "artifact": "pr-check-${{ matrix.name }}", + "run_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "status": "${{ steps.check.outcome == 'success' && 'success' || (steps.check.outcome == 'failure' && steps.recheck.outcome == 'success' && 'fix_pass' || 'failed') }}" + } + EOF + cat state.json - if (existing) { - await github.rest.issues.updateComment({ - comment_id: existing.id, - owner: context.repo.owner, - repo: context.repo.repo, - body - }); - } else { - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body - }); - } + - name: Upload state + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-check-state-${{ matrix.name }} + path: state.json - name: Fail if initial check failed if: steps.check.outcome == 'failure'