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
144 changes: 144 additions & 0 deletions .github/scripts/bug-server-manual.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use strict';

const { readFile, writeFile, appendFile, mkdtemp, rm } = require('node:fs/promises');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const { execFileSync } = require('node:child_process');
const { setTimeout: sleep } = require('node:timers/promises');

/** 控制脚本只运行于默认分支的可信 job,不安装或执行 PR 依赖。 */
function context(env) {
if (!['VisActor/VChart', 'VisActor/VTable'].includes(env.GITHUB_REPOSITORY)) throw new Error('Unsupported repository');
if (!env.BUG_SERVER_TOKEN) throw new Error('BUG_SERVER_TOKEN is not configured');
const runId = Number(env.REPORTED_RUN_ID || env.GITHUB_RUN_ID);
if (!Number.isSafeInteger(runId) || runId < 1) throw new Error('Invalid workflow run ID');
if (env.TASK_ID && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(env.TASK_ID)) throw new Error('Invalid task ID');
// 复用既有 GitHub CI 公网入口;内网页面入口另由 bugserver 的 Host 白名单控制。
const host = env.BUG_SERVER_HOST || 'https://bug-server.zijieapi.com';
if (!new Set(['https://bug-server.zijieapi.com', 'https://bugserver.cn.goofy.app', 'https://g20b7465b2.gf-boe.bytedance.net']).has(host)) throw new Error('Unsupported Bug Server host');
return { env, host, runId };
}

/** 每次重新构造 multipart;服务端 checkpoint 保证重试不重复创建 SCM/截图批次。 */
async function call(ctx, triggerType, fields = {}, filePath) {
let failure;
for (let attempt = 0; attempt < 8; attempt++) {
try {
const form = new FormData();
for (const [key, value] of Object.entries({ token: ctx.env.BUG_SERVER_TOKEN, product: ctx.env.GITHUB_REPOSITORY,
workflowRunId: String(ctx.runId), ...(ctx.env.TASK_ID ? { manualTaskId: ctx.env.TASK_ID } : {}), triggerType, ...fields })) {
form.append(key, String(value));
}
if (filePath) form.append('bundleFile', new Blob([await readFile(filePath)], { type: 'text/javascript' }), 'index.js');
const response = await fetch(`${ctx.host}/api/ci/trigger`, { method: 'POST', body: form,
signal: AbortSignal.timeout(75000), redirect: 'error' });
const result = await response.json();
if (!response.ok || result.code !== 0) {
const error = new Error(result.msg || `CI API returned ${response.status}`);
error.retryable = response.status >= 500 || error.message.includes('STEP_PENDING');
throw error;
}
return result.data;
} catch (error) {
failure = error;
if (error.retryable === false || (error.retryable === undefined && !['TypeError', 'TimeoutError', 'AbortError', 'SyntaxError'].includes(error.name))) throw error;
if (attempt < 7) await sleep(20000);
}
}
throw failure;
}

/** artifact 经 GitHub 重定向后不再携带 token,压缩包只交给可信的严格提取器。 */
async function downloadBundle(ctx, artifact, destination) {
if (!Number.isSafeInteger(artifact?.id) || artifact.id < 1 || !ctx.env.GH_TOKEN) throw new Error('Invalid artifact or missing GitHub token');
const initial = await fetch(`https://api.github.com/repos/${ctx.env.GITHUB_REPOSITORY}/actions/artifacts/${artifact.id}/zip`, {
headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', Authorization: `Bearer ${ctx.env.GH_TOKEN}` },
redirect: 'manual', signal: AbortSignal.timeout(20000),
});
if (initial.status !== 302) throw new Error('GitHub did not return an artifact download URL');
const location = new URL(initial.headers.get('location'));
if (location.protocol !== 'https:') throw new Error('Invalid artifact redirect');
const response = await fetch(location, { signal: AbortSignal.timeout(60000), redirect: 'error' });
if (!response.ok || !response.body) throw new Error('Artifact download failed');
const parts = [];
let size = 0;
for await (const part of response.body) {
size += part.length;
if (size > 72 * 1024 * 1024) throw new Error('Artifact archive exceeds the size limit');
parts.push(part);
}
const directory = await mkdtemp(join(tmpdir(), 'bugserver-archive-'));
try {
const archive = join(directory, 'bundle.zip');
await writeFile(archive, Buffer.concat(parts));
execFileSync('python3', [join(__dirname, 'extract_bug_server_bundle.py'), archive, destination], { stdio: 'pipe' });
} finally { await rm(directory, { recursive: true, force: true }); }
}

/** 冻结元数据写入 job outputs;复用失败时自动退回无凭据的构建 job。 */
async function prepare(ctx) {
const data = await call(ctx, 'manual-info');
if (!/^[a-f0-9]{40}$/.test(data.headSha) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(data.headRepository)) throw new Error('Invalid frozen PR metadata');
let reused = false;
if (data.artifact) {
try { await downloadBundle(ctx, data.artifact, join(ctx.env.RUNNER_TEMP, 'manual-bundle', 'index.js')); reused = true; }
catch { console.info('Existing artifact is unavailable or invalid; building the frozen head instead.'); }
}
await appendFile(ctx.env.GITHUB_OUTPUT, `head_sha=${data.headSha}\nhead_repository=${data.headRepository}\nreused=${reused}\n`);
}

/** 串行轮询有总截止时间,不使用可能重叠请求的 setInterval。 */
async function waitFor(action, finished, duration, label) {
const deadline = Date.now() + duration;
while (Date.now() < deadline) {
const result = await action();
if (finished(result)) return result;
await sleep(30000);
}
const error = new Error(`${label} timed out`);
error.outcome = 'timed_out';
throw error;
}

/** 新链路仅触发截图;产物是数据,始终从本轮固定 artifact 获取。 */
async function submit(ctx) {
const directory = await mkdtemp(join(tmpdir(), 'bugserver-submit-'));
try {
const { artifact } = await call(ctx, 'manual-artifact');
const file = join(directory, 'index.js');
await downloadBundle(ctx, artifact, file);
await call(ctx, 'upload-file', {}, file);
const { scmVersion } = await call(ctx, 'scm-build');
console.info(`SCM version: ${scmVersion}`);
await waitFor(() => call(ctx, 'scm-version-info'), result => {
if (result.status === 'build_failed') throw new Error('SCM build failed');
return result.status === 'build_ok';
}, 10 * 60000, 'SCM build');
const { bundleId } = await call(ctx, 'photo-test');
console.info(`Screenshot bundle: ${bundleId}`);
await waitFor(() => call(ctx, 'photo-result'), result => result.status === 'ok', 150 * 60000, 'Screenshot test');
const task = await call(ctx, 'manual-finish');
console.info(`Manual PR result: ${task.outcome}`);
return task.outcome === 'passed' ? 0 : 1;
} catch (error) {
// 失败结论也通过同一任务结束接口发送;独立 workflow_run 是取消/强制超时的兜底。
await call(ctx, 'manual-finish', { outcome: error.outcome || 'failed', error: error.message });
throw error;
} finally { await rm(directory, { recursive: true, force: true }); }
}

/** 完成回报只传源 run ID,具体任务和结论由服务端核对 GitHub 后确定。 */
async function run(mode, env = process.env) {
const ctx = context(env);
if (mode === 'prepare') { await prepare(ctx); return 0; }
if (mode === 'submit') return submit(ctx);
if (mode === 'complete') { await call(ctx, 'manual-finish'); return 0; }
throw new Error('Unknown manual workflow command');
}

module.exports = { context, call, downloadBundle, run };
if (require.main === module) run(process.argv[2]).then(code => { process.exitCode = code; }).catch(() => {
// 不输出响应正文、multipart、签名 URL 或 token;详情在 bugserver 任务记录中查看。
console.error('Manual PR workflow failed; see the Bug Server task record for details.');
process.exitCode = 1;
});
122 changes: 122 additions & 0 deletions .github/scripts/bug-server-manual.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { mkdtemp, readFile, rm, mkdir, writeFile, symlink, realpath } = require('node:fs/promises');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const { execFileSync } = require('node:child_process');
const { context, run } = require('./bug-server-manual.cjs');

const taskId = '10000000-0000-4000-8000-000000000001';
const env = { GITHUB_REPOSITORY: 'VisActor/VChart', GITHUB_RUN_ID: '99', TASK_ID: taskId, BUG_SERVER_TOKEN: 'test-project-token', GH_TOKEN: 'test-github-token' };

test('控制脚本拒绝任意仓库、入口、任务编号和 run ID', () => {
for (const patch of [{ GITHUB_REPOSITORY: 'evil/VChart' }, { TASK_ID: `${taskId}\nreused=true` },
{ GITHUB_RUN_ID: '0' }, { BUG_SERVER_TOKEN: '' }, { BUG_SERVER_HOST: 'https://evil.example' }]) assert.throws(() => context({ ...env, ...patch }));
});

test('BOE 控制请求只发送到固定 BOE 地址,保留任务与服务 token 校验', async t => {
const previousFetch = global.fetch;
t.after(() => { global.fetch = previousFetch; });
global.fetch = async (url, options) => {
assert.equal(url, 'https://g20b7465b2.gf-boe.bytedance.net/api/ci/trigger');
assert.equal(options.redirect, 'error');
assert.equal(options.body.get('token'), 'test-project-token');
assert.equal(options.body.get('workflowRunId'), '123');
return new Response(JSON.stringify({ code: 0, data: {} }));
};
await run('complete', { ...env, TASK_ID: undefined, REPORTED_RUN_ID: '123', BUG_SERVER_HOST: 'https://g20b7465b2.gf-boe.bytedance.net' });
for (const host of ['http://g20b7465b2.gf-boe.bytedance.net', 'https://g20b7465b2.gf-boe.bytedance.net.evil.example', 'https://g20b7465b2.gf-boe.bytedance.net/redirect']) {
assert.throws(() => context({ ...env, BUG_SERVER_HOST: host }));
}
});

test('prepare 使用服务端冻结 SHA,下载复用失败会退回构建', async t => {
const directory = await mkdtemp(join(tmpdir(), 'bugserver-control-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const previousFetch = global.fetch;
t.after(() => { global.fetch = previousFetch; });
global.fetch = async (url, options) => {
if (new URL(url).origin === 'https://api.github.com') return new Response('{}', { status: 404 });
assert.equal(options.body.get('triggerType'), 'manual-info');
assert.equal(options.body.get('manualTaskId'), taskId);
assert.equal(options.body.get('workflowRunId'), '99');
assert.equal(options.body.get('fileUrl'), null);
return new Response(JSON.stringify({ code: 0, data: { headSha: 'a'.repeat(40), headRepository: 'contributor/VChart', artifact: { id: 77 } } }));
};
await run('prepare', { ...env, RUNNER_TEMP: directory, GITHUB_OUTPUT: join(directory, 'outputs') });
assert.equal(await readFile(join(directory, 'outputs'), 'utf8'), `head_sha=${'a'.repeat(40)}\nhead_repository=contributor/VChart\nreused=false\n`);
});

test('submit 把可执行 JS 只当作上传数据,沿用 SCM/截图链路并保留差异结论', async t => {
const directory = await mkdtemp(join(tmpdir(), 'bugserver-control-test-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const archive = join(directory, 'bundle.zip');
const content = 'throw new Error("THIS_BUNDLE_MUST_NEVER_EXECUTE");';
execFileSync('python3', ['-c', 'import sys, zipfile\nwith zipfile.ZipFile(sys.argv[1], "w") as f: f.writestr("index.js", sys.argv[2])', archive, content]);
const bytes = await readFile(archive);
const previousFetch = global.fetch;
t.after(() => { global.fetch = previousFetch; });
const operations = [];
global.fetch = async (url, options) => {
const address = String(url);
if (new URL(address).origin === 'https://api.github.com') {
assert.equal(options.headers.Authorization, 'Bearer test-github-token');
assert.equal(options.redirect, 'manual');
return new Response(null, { status: 302, headers: { location: 'https://artifact.example/signed.zip' } });
}
if (new URL(address).origin === 'https://artifact.example') {
assert.equal(options.headers, undefined); // 签名 URL 请求不能携带 GitHub token。
return new Response(bytes);
}
assert.equal(options.redirect, 'error');
const form = options.body, type = form.get('triggerType');
operations.push(type);
assert.equal(form.get('token'), 'test-project-token');
let data;
if (type === 'manual-artifact') data = { artifact: { id: 77 } };
if (type === 'upload-file') { assert.equal(await form.get('bundleFile').text(), content); data = { fileUrl: 'https://tos.example/bundle.js' }; }
if (type === 'scm-build') { assert.equal(form.get('fileUrl'), null); data = { scmVersion: '42' }; }
if (type === 'scm-version-info') data = { status: 'build_ok' };
if (type === 'photo-test') data = { bundleId: 'aaaaaaaaaaaaaaaaaaaaaaaa', taskAmount: 1 };
if (type === 'photo-result') data = { status: 'ok', outcome: 'differences' };
if (type === 'manual-finish') data = { outcome: 'differences' };
assert.ok(data, `Unexpected operation ${type}`);
return new Response(JSON.stringify({ code: 0, data }));
};
assert.equal(await run('submit', env), 1);
assert.deepEqual(operations, ['manual-artifact', 'upload-file', 'scm-build', 'scm-version-info', 'photo-test', 'photo-result', 'manual-finish']);
assert.ok(!operations.includes('performance-test'));
});

test('独立完成回报只提供源 run ID,不从事件 payload 猜测被测 SHA 或结果', async t => {
const previousFetch = global.fetch;
t.after(() => { global.fetch = previousFetch; });
global.fetch = async (_url, options) => {
assert.equal(options.body.get('workflowRunId'), '123');
assert.equal(options.body.get('manualTaskId'), null);
assert.equal(options.body.get('outcome'), null);
return new Response(JSON.stringify({ code: 0, data: { outcome: 'cancelled' } }));
};
assert.equal(await run('complete', { ...env, TASK_ID: undefined, REPORTED_RUN_ID: '123' }), 0);
});

test('可信上传前检查拒绝文件及父目录符号链接、空文件和超限文件', async t => {
const directory = await realpath(await mkdtemp(join(tmpdir(), 'bugserver-upload-test-')));
t.after(() => rm(directory, { recursive: true, force: true }));
const workflow = await readFile(join(__dirname, __dirname.endsWith('github-manual-pr') ? 'VTable-bug-server-manual.yml' : '../workflows/bug-server-manual.yml'), 'utf8');
const match = workflow.match(/- name: Verify bundle before upload\n\s+run: \|\n([\s\S]*?)\n\s+- uses:/);
assert.ok(match, 'Trusted bundle verifier is required');
const script = match[1].replace(/^ {10}/gm, '');
const folder = join(directory, 'tools/bugserver-trigger/dist'), bundle = join(folder, 'index.js');
await mkdir(folder, { recursive: true });
const verify = () => execFileSync('bash', ['-e', '-c', script], { env: { ...process.env, GITHUB_WORKSPACE: directory }, stdio: 'pipe' });
await writeFile(bundle, 'valid bundle'); verify();
await writeFile(bundle, ''); assert.throws(verify);
execFileSync('python3', ['-c', 'import sys\nwith open(sys.argv[1], "wb") as f: f.truncate(64 * 1024 * 1024 + 1)', bundle]);
assert.throws(verify);
await rm(bundle); await writeFile(join(directory, 'private.txt'), 'must not upload');
await symlink(join(directory, 'private.txt'), bundle); assert.throws(verify);
await rm(folder, { recursive: true }); await mkdir(join(directory, 'outside'));
await writeFile(join(directory, 'outside/index.js'), 'must not upload');
await symlink(join(directory, 'outside'), folder); assert.throws(verify);
});
34 changes: 34 additions & 0 deletions .github/scripts/extract_bug_server_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Read a PR artifact as data without trusting archive paths or file attributes."""

import stat
import sys
import zipfile
from pathlib import Path

MAX_BUNDLE_BYTES = 64 * 1024 * 1024


def extract_bundle(archive_path, destination):
"""Extract only a bounded regular index.js entry without executing its contents."""
with zipfile.ZipFile(archive_path) as archive:
entries = archive.infolist()
if len(entries) != 1 or entries[0].filename != 'index.js':
raise ValueError('The PR artifact must contain exactly one file named index.js.')
entry = entries[0]
file_type = stat.S_IFMT(entry.external_attr >> 16)
if entry.is_dir() or file_type not in (0, stat.S_IFREG):
raise ValueError('The PR bundle must be a regular file, not a link or directory.')
if entry.file_size > MAX_BUNDLE_BYTES:
raise ValueError('The PR bundle exceeds the 64 MiB limit.')
with archive.open(entry) as source:
data = source.read(MAX_BUNDLE_BYTES + 1)
if len(data) > MAX_BUNDLE_BYTES:
raise ValueError('The PR bundle exceeds the 64 MiB limit.')
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open('xb') as output:
output.write(data)


if __name__ == '__main__':
extract_bundle(sys.argv[1], sys.argv[2])
Loading
Loading