Files
手瓜一十雪 b058604077 PR build: add dispatch script & secure workflow
Introduce a safe PR build dispatch flow and related helpers.

- Add GitHubAPI.createWorkflowDispatch to .github/scripts/lib/github.ts and tighten getRepository nullability.
- Add .github/scripts/pr-build-dispatch.ts to dispatch an untrusted workflow_run (workflow_dispatch) for PR builds.
- Rewrite .github/workflows/build-pr.yml to split privileged checks (pull_request_target / issue_comment) from untrusted execution (workflow_dispatch):
  - Add workflow_dispatch inputs for PR metadata.
  - Ensure privileged jobs only check conditions, post comments and dispatch the untrusted build.
  - Untrusted build jobs (framework/shell/publish) run under workflow_dispatch, checkout PR code into a separate workspace, clear GITHUB_TOKEN in build steps, and set stricter permissions and persist-credentials:false where appropriate.
  - Adjust concurrency, outputs, and inter-job dependencies to work with inputs rather than passing sensitive data between privileged and untrusted contexts.
- Update trigger-docker and result-comment flows to rely on workflow_dispatch inputs and only run in the untrusted run where appropriate.

- Change packages/napcat-vite/vite-plugin-version.js to read NAPCAT_GITHUB_TOKEN (instead of GITHUB_TOKEN) to avoid exposing Actions write tokens in untrusted builds.

These changes aim to improve security by preventing execution of PR-controlled code with elevated repo permissions and by isolating token usage.
2026-05-19 15:52:47 +08:00

322 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
export interface Release {
id: number;
tag_name: string;
name: string;
upload_url: string;
html_url: string;
created_at: string;
}
export interface ReleaseAsset {
id: number;
name: string;
browser_download_url: 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<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
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}`);
}
// 204 No Content(DELETE 等)不解析 body
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
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<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/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<void> {
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<void> {
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');
}
}
// ============== Release API ==============
async getReleaseByTag (owner: string, repo: string, tag: string): Promise<Release | null> {
try {
return await this.request<Release>(`/repos/${owner}/${repo}/releases/tags/${tag}`);
} catch {
return null;
}
}
async deleteRelease (owner: string, repo: string, releaseId: number): Promise<void> {
await this.request(`/repos/${owner}/${repo}/releases/${releaseId}`, { method: 'DELETE' });
}
async deleteRef (owner: string, repo: string, ref: string): Promise<void> {
try {
await this.request(`/repos/${owner}/${repo}/git/refs/${ref}`, { method: 'DELETE' });
} catch {
// ref may not exist, ignore
}
}
async createRelease (
owner: string,
repo: string,
tag: string,
name: string,
body: string,
prerelease = true
): Promise<Release> {
return this.request<Release>(`/repos/${owner}/${repo}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name: tag, name, body, prerelease }),
headers: { 'Content-Type': 'application/json' },
});
}
async uploadReleaseAsset (
uploadUrl: string,
fileName: string,
data: Buffer,
contentType = 'application/octet-stream'
): Promise<ReleaseAsset> {
const url = uploadUrl.replace(/\{[^}]*\}/g, '') + `?name=${encodeURIComponent(fileName)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'Content-Type': contentType,
'X-GitHub-Api-Version': '2022-11-28',
},
body: data,
});
if (!response.ok) {
throw new Error(`Upload ${fileName} failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<ReleaseAsset>;
}
// ============== Dispatch API ==============
async repositoryDispatch (
owner: string,
repo: string,
eventType: string,
clientPayload: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ event_type: eventType, client_payload: clientPayload }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Dispatch failed: ${response.status} ${response.statusText}`);
}
}
async createWorkflowDispatch (
owner: string,
repo: string,
workflowFile: string,
ref: string,
inputs: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ref, inputs }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Workflow dispatch failed: ${response.status} ${response.statusText}`);
}
}
// ============== Release Cleanup API ==============
async listReleases (owner: string, repo: string, perPage = 100, page = 1): Promise<Release[]> {
return this.request<Release[]>(
`/repos/${owner}/${repo}/releases?per_page=${perPage}&page=${page}`
);
}
}
// ============== 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`);
}
}
// ============== 环境变量工具 ==============
// Note: 不使用函数重载——Node.js --experimental-strip-types 不支持 TS overload signatures
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 };
}