diff --git a/.github/scripts/lib/comment.ts b/.github/scripts/lib/comment.ts new file mode 100644 index 00000000..ab970f49 --- /dev/null +++ b/.github/scripts/lib/comment.ts @@ -0,0 +1,121 @@ +/** + * 构建状态评论模板 + */ + +export const COMMENT_MARKER = ''; + +export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown'; + +export interface BuildTarget { + name: string; + status: BuildStatus; + error?: string; +} + +// ============== 辅助函数 ============== + +function formatSha (sha: string): string { + return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown'; +} + +function escapeCodeBlock (text: string): string { + // 替换 ``` 为转义形式,避免破坏 Markdown 代码块 + return text.replace(/```/g, '\\`\\`\\`'); +} + +// ============== 状态图标 ============== + +export function getStatusIcon (status: BuildStatus): string { + switch (status) { + case 'success': + return '✅ 成功'; + case 'pending': + return '⏳ 构建中...'; + case 'cancelled': + return '⚪ 已取消'; + default: + return '❌ 失败'; + } +} + +// ============== 构建中评论 ============== + +export function generateBuildingComment (prSha: string, targets: string[]): string { + const time = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC'; + + const lines: string[] = [ + COMMENT_MARKER, + '## 🔨 构建状态', + '', + '| 构建目标 | 状态 |', + '| :--- | :--- |', + ...targets.map(name => `| ${name} | ⏳ 构建中... |`), + '', + '---', + '', + `📝 **提交**: \`${formatSha(prSha)}\``, + `🕐 **开始时间**: ${time}`, + '', + '> 构建进行中,请稍候...', + ]; + + return lines.join('\n'); +} + +// ============== 构建结果评论 ============== + +export function generateResultComment ( + targets: BuildTarget[], + prSha: string, + runId: string, + repository: string +): string { + const artifactUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts`; + const logUrl = `https://github.com/${repository}/actions/runs/${runId}`; + + const allSuccess = targets.every(t => t.status === 'success'); + const anyCancelled = targets.some(t => t.status === 'cancelled'); + + const headerIcon = allSuccess + ? '✅ 构建成功' + : anyCancelled + ? '⚪ 构建已取消' + : '❌ 构建失败'; + + const downloadLink = (status: BuildStatus) => + status === 'success' ? `[📦 下载](${artifactUrl})` : '—'; + + const lines: string[] = [ + COMMENT_MARKER, + `## ${headerIcon}`, + '', + '| 构建目标 | 状态 | 下载 |', + '| :--- | :--- | :--- |', + ...targets.map(t => `| ${t.name} | ${getStatusIcon(t.status)} | ${downloadLink(t.status)} |`), + '', + '---', + '', + `📝 **提交**: \`${formatSha(prSha)}\``, + `🔗 **构建日志**: [查看详情](${logUrl})`, + ]; + + // 添加错误详情 + const failedTargets = targets.filter(t => t.status === 'failure' && t.error); + if (failedTargets.length > 0) { + lines.push('', '---', '', '## ⚠️ 错误详情'); + for (const target of failedTargets) { + lines.push('', `### ${target.name} 构建错误`, '```', escapeCodeBlock(target.error!), '```'); + } + } + + // 添加底部提示 + if (allSuccess) { + lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。'); + } else if (anyCancelled) { + lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。'); + } else { + lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。'); + } + + return lines.join('\n'); +} diff --git a/.github/scripts/lib/github.ts b/.github/scripts/lib/github.ts new file mode 100644 index 00000000..c0e82be9 --- /dev/null +++ b/.github/scripts/lib/github.ts @@ -0,0 +1,175 @@ +/** + * GitHub API 工具库 + */ + +import { appendFileSync } from 'node:fs'; + +// ============== 类型定义 ============== + +export interface PullRequest { + number: number; + state: string; + head: { + sha: string; + ref: string; + repo: { + full_name: string; + }; + }; +} + +export interface Repository { + owner: { + type: string; + }; +} + +// ============== GitHub API Client ============== + +export class GitHubAPI { + private token: string; + private baseUrl = 'https://api.github.com'; + + constructor (token: string) { + this.token = token; + } + + private async request (endpoint: string, options: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; + } + + async getPullRequest (owner: string, repo: string, pullNumber: number): Promise { + return this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}`); + } + + async getCollaboratorPermission (owner: string, repo: string, username: string): Promise { + const data = await this.request<{ permission: string; }>( + `/repos/${owner}/${repo}/collaborators/${username}/permission` + ); + return data.permission; + } + + async getRepository (owner: string, repo: string): Promise { + return this.request(`/repos/${owner}/${repo}`); + } + + async checkOrgMembership (org: string, username: string): Promise { + try { + await this.request(`/orgs/${org}/members/${username}`); + return true; + } catch { + return false; + } + } + + async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise { + await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise { + let page = 1; + const perPage = 100; + + while (page <= 10) { // 最多检查 1000 条评论 + const comments = await this.request>( + `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}` + ); + + if (comments.length === 0) { + return null; + } + + const found = comments.find(c => c.body.includes(marker)); + if (found) { + return found.id; + } + + if (comments.length < perPage) { + return null; + } + + page++; + } + + return null; + } + + async updateComment (owner: string, repo: string, commentId: number, body: string): Promise { + await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async createOrUpdateComment ( + owner: string, + repo: string, + issueNumber: number, + body: string, + marker: string + ): Promise { + const existingId = await this.findComment(owner, repo, issueNumber, marker); + if (existingId) { + await this.updateComment(owner, repo, existingId, body); + console.log(`✓ Updated comment #${existingId}`); + } else { + await this.createComment(owner, repo, issueNumber, body); + console.log('✓ Created new comment'); + } + } +} + +// ============== Output 工具 ============== + +export function setOutput (name: string, value: string): void { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(` ${name}=${value}`); +} + +export function setMultilineOutput (name: string, value: string): void { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + const delimiter = `EOF_${Date.now()}`; + appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`); + } +} + +// ============== 环境变量工具 ============== + +export function getEnv (name: string, required: true): string; +export function getEnv (name: string, required?: false): string | undefined; +export function getEnv (name: string, required = false): string | undefined { + const value = process.env[name]; + if (required && !value) { + throw new Error(`Environment variable ${name} is required`); + } + return value; +} + +export function getRepository (): { owner: string, repo: string; } { + const repository = getEnv('GITHUB_REPOSITORY', true); + const [owner, repo] = repository.split('/'); + return { owner, repo }; +} diff --git a/.github/scripts/pr-build-building.ts b/.github/scripts/pr-build-building.ts new file mode 100644 index 00000000..ddf91fe9 --- /dev/null +++ b/.github/scripts/pr-build-building.ts @@ -0,0 +1,36 @@ +/** + * PR Build - 更新构建中状态评论 + * + * 环境变量: + * - GITHUB_TOKEN: GitHub API Token + * - PR_NUMBER: PR 编号 + * - PR_SHA: PR 提交 SHA + */ + +import { GitHubAPI, getEnv, getRepository } from './lib/github.ts'; +import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts'; + +const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell']; + +async function main (): Promise { + console.log('🔨 Updating building status comment\n'); + + const token = getEnv('GITHUB_TOKEN', true); + const prNumber = parseInt(getEnv('PR_NUMBER', true), 10); + const prSha = getEnv('PR_SHA', true); + const { owner, repo } = getRepository(); + + console.log(`PR: #${prNumber}`); + console.log(`SHA: ${prSha}`); + console.log(`Repo: ${owner}/${repo}\n`); + + const github = new GitHubAPI(token); + const comment = generateBuildingComment(prSha, BUILD_TARGETS); + + await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER); +} + +main().catch((error) => { + console.error('❌ Error:', error); + process.exit(1); +}); diff --git a/.github/scripts/pr-build-check.ts b/.github/scripts/pr-build-check.ts new file mode 100644 index 00000000..8397f441 --- /dev/null +++ b/.github/scripts/pr-build-check.ts @@ -0,0 +1,205 @@ +/** + * PR Build Check Script + * 检查 PR 构建触发条件和用户权限 + * + * 环境变量: + * - GITHUB_TOKEN: GitHub API Token + * - GITHUB_EVENT_NAME: 事件名称 + * - GITHUB_EVENT_PATH: 事件 payload 文件路径 + * - GITHUB_REPOSITORY: 仓库名称 (owner/repo) + * - GITHUB_OUTPUT: 输出文件路径 + */ + +import { readFileSync } from 'node:fs'; +import { GitHubAPI, getEnv, getRepository, setOutput, PullRequest } from './lib/github.ts'; + +// ============== 类型定义 ============== + +interface GitHubPayload { + pull_request?: PullRequest; + issue?: { + number: number; + pull_request?: object; + }; + comment?: { + body: string; + user: { login: string; }; + }; +} + +interface CheckResult { + should_build: boolean; + pr_number?: number; + pr_sha?: string; + pr_head_repo?: string; + pr_head_ref?: string; +} + +// ============== 权限检查 ============== + +async function checkUserPermission ( + github: GitHubAPI, + owner: string, + repo: string, + username: string +): Promise { + // 方法1:检查仓库协作者权限 + try { + const permission = await github.getCollaboratorPermission(owner, repo, username); + if (['admin', 'write', 'maintain'].includes(permission)) { + console.log(`✓ User ${username} has ${permission} permission`); + return true; + } + console.log(`✗ User ${username} has ${permission} permission (insufficient)`); + } catch (e) { + console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`); + } + + // 方法2:检查组织成员身份 + try { + const repoInfo = await github.getRepository(owner, repo); + if (repoInfo.owner.type === 'Organization') { + const isMember = await github.checkOrgMembership(owner, username); + if (isMember) { + console.log(`✓ User ${username} is organization member`); + return true; + } + console.log(`✗ User ${username} is not organization member`); + } + } catch (e) { + console.log(`✗ Failed to check org membership: ${(e as Error).message}`); + } + + return false; +} + +// ============== 事件处理 ============== + +function handlePullRequestTarget (payload: GitHubPayload): CheckResult { + const pr = payload.pull_request; + + if (!pr) { + console.log('✗ No pull_request in payload'); + return { should_build: false }; + } + + if (pr.state !== 'open') { + console.log(`✗ PR is not open (state: ${pr.state})`); + return { should_build: false }; + } + + console.log(`✓ PR #${pr.number} is open, triggering build`); + return { + should_build: true, + pr_number: pr.number, + pr_sha: pr.head.sha, + pr_head_repo: pr.head.repo.full_name, + pr_head_ref: pr.head.ref, + }; +} + +async function handleIssueComment ( + payload: GitHubPayload, + github: GitHubAPI, + owner: string, + repo: string +): Promise { + const { issue, comment } = payload; + + if (!issue || !comment) { + console.log('✗ No issue or comment in payload'); + return { should_build: false }; + } + + // 检查是否是 PR 的评论 + if (!issue.pull_request) { + console.log('✗ Comment is not on a PR'); + return { should_build: false }; + } + + // 检查是否是 /build 命令 + if (!comment.body.trim().startsWith('/build')) { + console.log('✗ Comment is not a /build command'); + return { should_build: false }; + } + + console.log(`→ /build command from @${comment.user.login}`); + + // 获取 PR 详情 + const pr = await github.getPullRequest(owner, repo, issue.number); + + // 检查 PR 状态 + if (pr.state !== 'open') { + console.log(`✗ PR is not open (state: ${pr.state})`); + await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。'); + return { should_build: false }; + } + + // 检查用户权限 + const username = comment.user.login; + const hasPermission = await checkUserPermission(github, owner, repo, username); + + if (!hasPermission) { + console.log(`✗ User ${username} has no permission`); + await github.createComment( + owner, + repo, + issue.number, + `⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。` + ); + return { should_build: false }; + } + + console.log(`✓ Build triggered by @${username}`); + return { + should_build: true, + pr_number: issue.number, + pr_sha: pr.head.sha, + pr_head_repo: pr.head.repo.full_name, + pr_head_ref: pr.head.ref, + }; +} + +// ============== 主函数 ============== + +async function main (): Promise { + console.log('🔍 PR Build Check\n'); + + const token = getEnv('GITHUB_TOKEN', true); + const eventName = getEnv('GITHUB_EVENT_NAME', true); + const eventPath = getEnv('GITHUB_EVENT_PATH', true); + const { owner, repo } = getRepository(); + + console.log(`Event: ${eventName}`); + console.log(`Repository: ${owner}/${repo}\n`); + + const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload; + const github = new GitHubAPI(token); + + let result: CheckResult; + + switch (eventName) { + case 'pull_request_target': + result = handlePullRequestTarget(payload); + break; + case 'issue_comment': + result = await handleIssueComment(payload, github, owner, repo); + break; + default: + console.log(`✗ Unsupported event: ${eventName}`); + result = { should_build: false }; + } + + // 输出结果 + console.log('\n=== Outputs ==='); + setOutput('should_build', String(result.should_build)); + setOutput('pr_number', String(result.pr_number ?? '')); + setOutput('pr_sha', result.pr_sha ?? ''); + setOutput('pr_head_repo', result.pr_head_repo ?? ''); + setOutput('pr_head_ref', result.pr_head_ref ?? ''); +} + +main().catch((error) => { + console.error('❌ Error:', error); + process.exit(1); +}); diff --git a/.github/scripts/pr-build-result.ts b/.github/scripts/pr-build-result.ts new file mode 100644 index 00000000..ca1ffe29 --- /dev/null +++ b/.github/scripts/pr-build-result.ts @@ -0,0 +1,60 @@ +/** + * PR Build - 更新构建结果评论 + * + * 环境变量: + * - GITHUB_TOKEN: GitHub API Token + * - PR_NUMBER: PR 编号 + * - PR_SHA: PR 提交 SHA + * - RUN_ID: GitHub Actions Run ID + * - FRAMEWORK_STATUS: Framework 构建状态 + * - FRAMEWORK_ERROR: Framework 构建错误信息 + * - SHELL_STATUS: Shell 构建状态 + * - SHELL_ERROR: Shell 构建错误信息 + */ + +import { GitHubAPI, getEnv, getRepository } from './lib/github.ts'; +import { generateResultComment, COMMENT_MARKER, BuildTarget, BuildStatus } from './lib/comment.ts'; + +function parseStatus (value: string | undefined): BuildStatus { + if (value === 'success' || value === 'failure' || value === 'cancelled') { + return value; + } + return 'unknown'; +} + +async function main (): Promise { + console.log('📝 Updating build result comment\n'); + + const token = getEnv('GITHUB_TOKEN', true); + const prNumber = parseInt(getEnv('PR_NUMBER', true), 10); + const prSha = getEnv('PR_SHA') || 'unknown'; + const runId = getEnv('RUN_ID', true); + const { owner, repo } = getRepository(); + + const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS')); + const frameworkError = getEnv('FRAMEWORK_ERROR'); + const shellStatus = parseStatus(getEnv('SHELL_STATUS')); + const shellError = getEnv('SHELL_ERROR'); + + console.log(`PR: #${prNumber}`); + console.log(`SHA: ${prSha}`); + console.log(`Run: ${runId}`); + console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`); + console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`); + + const targets: BuildTarget[] = [ + { name: 'NapCat.Framework', status: frameworkStatus, error: frameworkError }, + { name: 'NapCat.Shell', status: shellStatus, error: shellError }, + ]; + + const github = new GitHubAPI(token); + const repository = `${owner}/${repo}`; + const comment = generateResultComment(targets, prSha, runId, repository); + + await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER); +} + +main().catch((error) => { + console.error('❌ Error:', error); + process.exit(1); +}); diff --git a/.github/scripts/pr-build-run.ts b/.github/scripts/pr-build-run.ts new file mode 100644 index 00000000..8366f2c8 --- /dev/null +++ b/.github/scripts/pr-build-run.ts @@ -0,0 +1,149 @@ +/** + * PR Build Runner + * 执行构建步骤 + * + * 用法: node pr-build-run.ts + * target: framework | shell + */ + +import { execSync } from 'node:child_process'; +import { existsSync, renameSync, unlinkSync } from 'node:fs'; +import { setOutput } from './lib/github.ts'; + +type BuildTarget = 'framework' | 'shell'; + +interface BuildStep { + name: string; + command: string; + errorMessage: string; +} + +// ============== 构建步骤 ============== + +function getCommonSteps (): BuildStep[] { + return [ + { + name: 'Install pnpm', + command: 'npm i -g pnpm', + errorMessage: 'Failed to install pnpm', + }, + { + name: 'Install dependencies', + command: 'pnpm i', + errorMessage: 'Failed to install dependencies', + }, + { + name: 'Type check', + command: 'pnpm run typecheck', + errorMessage: 'Type check failed', + }, + { + name: 'Test', + command: 'pnpm test', + errorMessage: 'Tests failed', + }, + { + name: 'Build WebUI', + command: 'pnpm --filter napcat-webui-frontend run build', + errorMessage: 'WebUI build failed', + }, + ]; +} + +function getTargetSteps (target: BuildTarget): BuildStep[] { + if (target === 'framework') { + return [ + { + name: 'Build Framework', + command: 'pnpm run build:framework', + errorMessage: 'Framework build failed', + }, + ]; + } + return [ + { + name: 'Build Shell', + command: 'pnpm run build:shell', + errorMessage: 'Shell build failed', + }, + ]; +} + +// ============== 执行器 ============== + +function runStep (step: BuildStep): boolean { + console.log(`\n::group::${step.name}`); + console.log(`> ${step.command}\n`); + + try { + execSync(step.command, { + stdio: 'inherit', + shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', + }); + console.log('::endgroup::'); + console.log(`✓ ${step.name}`); + return true; + } catch (_error) { + console.log('::endgroup::'); + console.log(`✗ ${step.name}`); + setOutput('error', step.errorMessage); + return false; + } +} + +function postBuild (target: BuildTarget): void { + const srcDir = target === 'framework' + ? 'packages/napcat-framework/dist' + : 'packages/napcat-shell/dist'; + const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist'; + + console.log(`\n→ Moving ${srcDir} to ${destDir}`); + + if (!existsSync(srcDir)) { + throw new Error(`Build output not found: ${srcDir}`); + } + + renameSync(srcDir, destDir); + + // Install production dependencies + console.log('→ Installing production dependencies'); + execSync('npm install --omit=dev', { + cwd: destDir, + stdio: 'inherit', + shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', + }); + + // Remove package-lock.json + const lockFile = `${destDir}/package-lock.json`; + if (existsSync(lockFile)) { + unlinkSync(lockFile); + } + + console.log(`✓ Build output ready at ${destDir}`); +} + +// ============== 主函数 ============== + +function main (): void { + const target = process.argv[2] as BuildTarget; + + if (!target || !['framework', 'shell'].includes(target)) { + console.error('Usage: node pr-build-run.ts '); + process.exit(1); + } + + console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`); + + const steps = [...getCommonSteps(), ...getTargetSteps(target)]; + + for (const step of steps) { + if (!runStep(step)) { + process.exit(1); + } + } + + postBuild(target); + console.log('\n✅ Build completed successfully!'); +} + +main(); diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..b631af87 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,252 @@ +# ============================================================================= +# PR 构建工作流 +# ============================================================================= +# 功能: +# 1. 在 PR 提交时自动构建 Framework 和 Shell 包 +# 2. 支持通过 /build 命令手动触发构建(仅协作者/组织成员) +# 3. 在 PR 中发布构建状态评论,并持续更新(不会重复创建) +# 4. 支持 Fork PR 的构建(使用 pull_request_target 获取写权限) +# +# 安全说明: +# - 使用 pull_request_target 事件,在 base 分支上下文运行 +# - 构建脚本始终从 base 分支 checkout,避免恶意 PR 篡改脚本 +# - PR 代码单独 checkout 到 workspace 目录 +# ============================================================================= + +name: PR Build + +# ============================================================================= +# 触发条件 +# ============================================================================= +on: + # PR 事件:打开、同步(新推送)、重新打开时触发 + # 注意:使用 pull_request_target 而非 pull_request,以便对 Fork PR 有写权限 + pull_request_target: + types: [opened, synchronize, reopened] + + # Issue 评论事件:用于响应 /build 命令 + issue_comment: + types: [created] + +# ============================================================================= +# 权限配置 +# ============================================================================= +permissions: + contents: read # 读取仓库内容 + pull-requests: write # 写入 PR 评论 + issues: write # 写入 Issue 评论(/build 命令响应) + actions: read # 读取 Actions 信息(获取构建日志链接) + +# ============================================================================= +# 并发控制 +# ============================================================================= +# 同一 PR 的多次构建会取消之前未完成的构建,避免资源浪费 +concurrency: + group: pr-build-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +# ============================================================================= +# 任务定义 +# ============================================================================= +jobs: + # --------------------------------------------------------------------------- + # Job 1: 检查构建条件 + # --------------------------------------------------------------------------- + # 判断是否应该触发构建: + # - pull_request_target 事件:总是触发 + # - issue_comment 事件:检查是否为 /build 命令,且用户有权限 + # --------------------------------------------------------------------------- + check-build: + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check.outputs.should_build }} # 是否应该构建 + pr_number: ${{ steps.check.outputs.pr_number }} # PR 编号 + pr_sha: ${{ steps.check.outputs.pr_sha }} # PR 最新提交 SHA + pr_head_repo: ${{ steps.check.outputs.pr_head_repo }} # PR 源仓库(用于 Fork) + pr_head_ref: ${{ steps.check.outputs.pr_head_ref }} # PR 源分支 + steps: + # 仅 checkout 脚本目录,加快速度 + - name: Checkout scripts + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + # 使用 Node.js 24 以支持原生 TypeScript 执行 + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + # 执行检查脚本,判断是否触发构建 + - name: Check trigger condition + id: check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node --experimental-strip-types .github/scripts/pr-build-check.ts + + # --------------------------------------------------------------------------- + # Job 2: 更新评论为"构建中"状态 + # --------------------------------------------------------------------------- + # 在 PR 中创建或更新评论,显示构建正在进行中 + # --------------------------------------------------------------------------- + update-comment-building: + needs: check-build + if: needs.check-build.outputs.should_build == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout scripts + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + # 更新 PR 评论,显示构建中状态 + - name: Update building comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ needs.check-build.outputs.pr_number }} + PR_SHA: ${{ needs.check-build.outputs.pr_sha }} + run: node --experimental-strip-types .github/scripts/pr-build-building.ts + + # --------------------------------------------------------------------------- + # Job 3: 构建 Framework 包 + # --------------------------------------------------------------------------- + # 执行 napcat-framework 的构建流程 + # --------------------------------------------------------------------------- + build-framework: + needs: [check-build, update-comment-building] + if: needs.check-build.outputs.should_build == 'true' + runs-on: ubuntu-latest + outputs: + status: ${{ steps.build.outcome }} # 构建结果:success/failure + error: ${{ steps.build.outputs.error }} # 错误信息(如有) + steps: + # 【安全】先从 base 分支 checkout 构建脚本 + # 这样即使 PR 中修改了脚本,也不会被执行 + - name: Checkout scripts from base + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + path: _scripts + + # 将 PR 代码 checkout 到单独的 workspace 目录 + - name: Checkout PR code + uses: actions/checkout@v4 + with: + repository: ${{ needs.check-build.outputs.pr_head_repo }} + ref: ${{ needs.check-build.outputs.pr_sha }} + path: workspace + + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + # 执行构建,使用 base 分支的脚本处理 workspace 中的代码 + - name: Build + id: build + working-directory: workspace + run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework + continue-on-error: true # 允许失败,后续更新评论时处理 + + # 构建成功时上传产物 + - name: Upload Artifact + if: steps.build.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: NapCat.Framework + path: workspace/framework-dist + retention-days: 7 # 保留 7 天 + + # --------------------------------------------------------------------------- + # Job 4: 构建 Shell 包 + # --------------------------------------------------------------------------- + # 执行 napcat-shell 的构建流程(与 Framework 并行执行) + # --------------------------------------------------------------------------- + build-shell: + needs: [check-build, update-comment-building] + if: needs.check-build.outputs.should_build == 'true' + runs-on: ubuntu-latest + outputs: + status: ${{ steps.build.outcome }} # 构建结果:success/failure + error: ${{ steps.build.outputs.error }} # 错误信息(如有) + steps: + # 【安全】先从 base 分支 checkout 构建脚本 + - name: Checkout scripts from base + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + path: _scripts + + # 将 PR 代码 checkout 到单独的 workspace 目录 + - name: Checkout PR code + uses: actions/checkout@v4 + with: + repository: ${{ needs.check-build.outputs.pr_head_repo }} + ref: ${{ needs.check-build.outputs.pr_sha }} + path: workspace + + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + # 执行构建 + - name: Build + id: build + working-directory: workspace + run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell + continue-on-error: true + + # 构建成功时上传产物 + - name: Upload Artifact + if: steps.build.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: NapCat.Shell + path: workspace/shell-dist + retention-days: 7 # 保留 7 天 + + # --------------------------------------------------------------------------- + # Job 5: 更新评论为构建结果 + # --------------------------------------------------------------------------- + # 汇总所有构建结果,更新 PR 评论显示最终状态 + # 使用 always() 确保即使构建失败/取消也会执行 + # --------------------------------------------------------------------------- + update-comment-result: + needs: [check-build, update-comment-building, build-framework, build-shell] + if: always() && needs.check-build.outputs.should_build == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout scripts + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + - name: Setup Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + # 更新评论,显示构建结果和下载链接 + - name: Update result comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ needs.check-build.outputs.pr_number }} + PR_SHA: ${{ needs.check-build.outputs.pr_sha }} + RUN_ID: ${{ github.run_id }} + # 获取构建状态,如果 job 被跳过则标记为 cancelled + FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }} + FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }} + SHELL_STATUS: ${{ needs.build-shell.outputs.status || 'cancelled' }} + SHELL_ERROR: ${{ needs.build-shell.outputs.error }} + run: node --experimental-strip-types .github/scripts/pr-build-result.ts