Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -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-<platform>`: 包含 check.log, check-fix.log, check-recheck.log
- `pr-check-state-<platform>`: 包含平台状态的 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 <run-id> --log

# 查看 PR Check 的 artifacts
gh api repos/OWNER/REPO/actions/runs/<run-id>/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)
223 changes: 223 additions & 0 deletions .github/workflows/pr-check-comment.yml
Original file line number Diff line number Diff line change
@@ -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 = '<!-- duckcoding-pr-check -->';
const stateMarker = '<!-- duckcoding-pr-check-state:';
const platforms = ['ubuntu-22.04', 'windows-latest', 'macos-arm64', 'macos-x64'];

// 获取 PR 编号
const prNumber = context.payload.workflow_run.pull_requests[0]?.number;
if (!prNumber) {
console.log('No PR number found, skipping comment update');
return;
}

console.log(`Updating comment for PR #${prNumber}`);

// 读取所有平台的状态
const defaultState = () =>
Object.fromEntries(
platforms.map((p) => [
p,
{
platform: p,
status: 'pending',
check: 'pending',
fix: 'pending',
recheck: 'pending',
artifact: '',
run_url: ''
}
])
);

let state = defaultState();

// 从 artifact 中读取各平台状态
const statesDir = 'states';
if (fs.existsSync(statesDir)) {
for (const platform of platforms) {
const stateFile = path.join(statesDir, `${platform}.json`);
if (fs.existsSync(stateFile)) {
try {
const platformState = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
state[platform] = platformState;
console.log(`Loaded state for ${platform}:`, platformState);
} catch (e) {
console.error(`Failed to parse state for ${platform}:`, e);
}
}
}
}

const statusLabelZh = (entry) => {
if (entry.status === 'pending') return '⏳ 运行中...';
if (entry.status === 'success') return '✅ 直接通过';
if (entry.status === 'fix_pass') return '🟡 自动修复后通过(请本地提交修复)';
return '❌ 仍未通过';
};

const statusLabelEn = (entry) => {
if (entry.status === 'pending') return '⏳ In progress...';
if (entry.status === 'success') return '✅ Passed';
if (entry.status === 'fix_pass') return '🟡 Passed after auto-fix (commit locally)';
return '❌ Still failing';
};

const detail = (entry) =>
entry.status === 'pending'
? '-'
: `check=${entry.check} / fix=${entry.fix} / recheck=${entry.recheck}`;

const linkOrDash = (entry) => (entry.run_url ? `[日志](${entry.run_url})` : '-');
const artifactOrDash = (entry) => (entry.status === 'pending' ? '-' : entry.artifact || '-');

const pLabel = (p) => {
if (p === 'ubuntu-22.04') return 'ubuntu-22.04';
if (p === 'windows-latest') return 'windows-latest';
if (p === 'macos-arm64') return 'macos-14 (arm64)';
if (p === 'macos-x64') return 'macos-15 (x64)';
return p;
};

const renderTable = (labelFn, header) => {
const rows = platforms
.map((p) => {
const e = state[p] || defaultState()[p];
return `| ${pLabel(p)} | ${labelFn(e)} | ${detail(e)} | ${artifactOrDash(e)} | ${linkOrDash(e)} |`;
})
.join('\n');
return [
`| ${header.platform} | ${header.status} | ${header.detail} | ${header.artifact} | ${header.link} |`,
'| --- | --- | --- | --- | --- |',
rows
].join('\n');
};

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');

// 查找现有评论
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
});
}
Loading
Loading