diff --git a/.github/scripts/bug-server-manual.cjs b/.github/scripts/bug-server-manual.cjs new file mode 100644 index 000000000..46b2678af --- /dev/null +++ b/.github/scripts/bug-server-manual.cjs @@ -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; +}); diff --git a/.github/scripts/bug-server-manual.test.cjs b/.github/scripts/bug-server-manual.test.cjs new file mode 100644 index 000000000..02d801d2c --- /dev/null +++ b/.github/scripts/bug-server-manual.test.cjs @@ -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); +}); diff --git a/.github/scripts/extract_bug_server_bundle.py b/.github/scripts/extract_bug_server_bundle.py new file mode 100644 index 000000000..88ef32f24 --- /dev/null +++ b/.github/scripts/extract_bug_server_bundle.py @@ -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]) diff --git a/.github/scripts/test_extract_bug_server_bundle.py b/.github/scripts/test_extract_bug_server_bundle.py new file mode 100644 index 000000000..893af7068 --- /dev/null +++ b/.github/scripts/test_extract_bug_server_bundle.py @@ -0,0 +1,78 @@ +import stat +import tempfile +import unittest +import warnings +import zipfile +from pathlib import Path +from unittest.mock import patch + +from extract_bug_server_bundle import extract_bundle + + +class ExtractBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / 'bundle.zip' + self.destination = self.root / 'dist' / 'index.js' + + def archive_entries(self, entries): + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + with zipfile.ZipFile(self.archive, 'w', zipfile.ZIP_DEFLATED) as archive: + for name, data in entries: + archive.writestr(name, data) + + def test_preserves_binary_content(self): + data = b'\x00\xffbundle\n' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_executable_text_is_only_data(self): + data = b'throw new Error("BUNDLE_MUST_NOT_EXECUTE");' + self.archive_entries([('index.js', data)]) + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), data) + + def test_rejects_unexpected_names_and_extra_files(self): + for entries in [ + [], [('index.js', b'a'), ('scripts/trigger-test.ts', b'evil')], + [('../scripts/trigger-test.ts', b'evil')], [('/tmp/index.js', b'evil')], + [('index.js', b'a'), ('index.js', b'b')], [('folder/index.js', b'a')], + ]: + with self.subTest(entries=entries): + self.archive_entries(entries) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for file_type in [stat.S_IFLNK, stat.S_IFDIR, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(file_type=file_type): + entry = zipfile.ZipInfo('index.js') + entry.create_system = 3 + entry.external_attr = (file_type | 0o777) << 16 + self.archive_entries([(entry, b'../scripts/trigger-test.ts')]) + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + + def test_rejects_oversized_bundle(self): + self.archive_entries([('index.js', b'x' * 1025)]) + with patch('extract_bug_server_bundle.MAX_BUNDLE_BYTES', 1024): + with self.assertRaises(ValueError): + extract_bundle(self.archive, self.destination) + self.assertFalse(self.destination.exists()) + + def test_does_not_overwrite_existing_file(self): + self.archive_entries([('index.js', b'new')]) + self.destination.parent.mkdir() + self.destination.write_bytes(b'original') + with self.assertRaises(FileExistsError): + extract_bundle(self.archive, self.destination) + self.assertEqual(self.destination.read_bytes(), b'original') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/bug-server-manual-report.yml b/.github/workflows/bug-server-manual-report.yml new file mode 100644 index 000000000..1addfdd9d --- /dev/null +++ b/.github/workflows/bug-server-manual-report.yml @@ -0,0 +1,31 @@ +name: Bug Server Manual PR Report + +on: + workflow_run: + workflows: ['Bug Server Manual PR'] + types: [completed] + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # 取消或强制超时也会触发回报,服务端根据源 run 校验任务和真实结论。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Report source run completion + env: + # 只从源 run 的固定环境后缀选择回报地址,不接受任意 URL。 + BUG_SERVER_HOST: ${{ endsWith(github.event.workflow_run.display_title, ':boe') && 'https://g20b7465b2.gf-boe.bytedance.net' || 'https://bug-server.zijieapi.com' }} + REPORTED_RUN_ID: ${{ github.event.workflow_run.id }} + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + run: node .github/scripts/bug-server-manual.cjs complete diff --git a/.github/workflows/bug-server-manual.yml b/.github/workflows/bug-server-manual.yml new file mode 100644 index 000000000..5f90ffe51 --- /dev/null +++ b/.github/workflows/bug-server-manual.yml @@ -0,0 +1,129 @@ +name: Bug Server Manual PR +run-name: bugserver-manual:${{ inputs.task_id }}${{ inputs.environment == 'boe' && ':boe' || '' }} + +on: + workflow_dispatch: + inputs: + task_id: + description: 'Bug Server 创建的冻结任务 UUID(仅传任务编号)' + required: true + type: string + environment: + description: 'Bug Server 任务所属环境' + required: false + default: cn + type: choice + options: [cn, boe] + +permissions: + contents: read + +jobs: + prepare: + name: prepare + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read + outputs: + head_sha: ${{ steps.target.outputs.head_sha }} + head_repository: ${{ steps.target.outputs.head_repository }} + reused: ${{ steps.target.outputs.reused }} + steps: + # 控制脚本固定在启动 workflow 时的默认分支提交,只把 PR 产物作为数据读取。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Resolve frozen task and try artifact reuse + id: target + env: + TASK_ID: ${{ inputs.task_id }} + BUG_SERVER_HOST: ${{ inputs.environment == 'boe' && 'https://g20b7465b2.gf-boe.bytedance.net' || 'https://bug-server.zijieapi.com' }} + GH_TOKEN: ${{ github.token }} + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + run: node .github/scripts/bug-server-manual.cjs prepare + - uses: actions/upload-artifact@v4 + if: steps.target.outputs.reused == 'true' + with: + name: bug-server-manual-${{ inputs.task_id }} + path: ${{ runner.temp }}/manual-bundle/index.js + if-no-files-found: error + retention-days: 7 + + build-bundle: + name: build-bundle + needs: prepare + if: needs.prepare.outputs.reused != 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # 先用可信 Dockerfile 准备镜像,不将 PR 目录作为 Docker 构建上下文。 + - name: Prepare isolated build image + run: | + docker build --tag bugserver-pr-build - <<'DOCKERFILE' + FROM node:22-bookworm + RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-setuptools pkg-config libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev && rm -rf /var/lib/apt/lists/* + DOCKERFILE + # PR 代码只在容器内执行;只挂载源码,不传 runner/cache token 或 Docker socket。 + - uses: actions/checkout@v4 + with: + repository: ${{ needs.prepare.outputs.head_repository }} + ref: ${{ needs.prepare.outputs.head_sha }} + persist-credentials: false + - name: Verify frozen checkout + env: + EXPECTED_SHA: ${{ needs.prepare.outputs.head_sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Build frozen head in isolated container + run: | + docker run --rm --init --cap-drop=ALL --security-opt=no-new-privileges \ + --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$GITHUB_WORKSPACE,target=/workspace" \ + --workdir /workspace --env HOME=/tmp --env NODE_OPTIONS=--max_old_space_size=4096 \ + bugserver-pr-build sh -ec 'node common/scripts/install-run-rush.js install --bypass-policy && node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger' + # 容器退出后,拒绝 symlink/目录及超限产物,避免上传动作读取 runner 文件。 + - name: Verify bundle before upload + run: | + artifact_file="$GITHUB_WORKSPACE/tools/bugserver-trigger/dist/index.js" + test "$(realpath "$artifact_file")" = "$artifact_file" + test -f "$artifact_file" && test ! -L "$artifact_file" + test "$(wc -c < "$artifact_file")" -gt 0 + test "$(wc -c < "$artifact_file")" -le 67108864 + - uses: actions/upload-artifact@v4 + with: + name: bug-server-manual-${{ inputs.task_id }} + path: tools/bugserver-trigger/dist/index.js + if-no-files-found: error + retention-days: 7 + + submit: + needs: [prepare, build-bundle] + if: always() && needs.prepare.result == 'success' && (needs.prepare.outputs.reused == 'true' || needs.build-bundle.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + actions: read + steps: + # 不 checkout 或执行 PR 脚本;上传、SCM 和截图控制只使用可信提交。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Submit bundle and wait for screenshot results + env: + TASK_ID: ${{ inputs.task_id }} + BUG_SERVER_HOST: ${{ inputs.environment == 'boe' && 'https://g20b7465b2.gf-boe.bytedance.net' || 'https://bug-server.zijieapi.com' }} + GH_TOKEN: ${{ github.token }} + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + run: node .github/scripts/bug-server-manual.cjs submit