diff --git a/.github/scripts/bug-server-dispatch.cjs b/.github/scripts/bug-server-dispatch.cjs new file mode 100644 index 0000000000..815be0bec9 --- /dev/null +++ b/.github/scripts/bug-server-dispatch.cjs @@ -0,0 +1,98 @@ +async function resolveBugServerTarget({ github, context, prNumber, headSha }) { + const defaultBranch = context.payload.repository.default_branch; + if (context.ref !== `refs/heads/${defaultBranch}`) { + throw new Error(`Run this workflow from the default branch (${defaultBranch}).`); + } + if (!/^[1-9][0-9]*$/.test(prNumber) || !Number.isSafeInteger(Number(prNumber))) { + throw new Error('PR number must be a positive integer.'); + } + if (headSha.length !== 40 || !/^[0-9a-f]+$/i.test(headSha)) { + throw new Error('Head SHA must be a full 40-character hexadecimal commit SHA.'); + } + + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: Number(prNumber) + }); + const repository = `${context.repo.owner}/${context.repo.repo}`; + if (pull.base.repo.full_name !== repository) { + throw new Error(`PR base repository must be ${repository}.`); + } + const sha = headSha.toLowerCase(); + if (pull.head.sha !== sha) { + throw new Error( + `PR #${prNumber} head changed: expected ${sha}, current ${pull.head.sha}. Review the current head before retrying.` + ); + } + + if (!pull.head.repo) { + throw new Error('The PR head repository no longer exists.'); + } + const workflowPath = '.github/workflows/bug-server-pr-bundle.yml'; + const { data: workflow } = await github.rest.actions.getWorkflow({ + ...context.repo, + workflow_id: 'bug-server-pr-bundle.yml' + }); + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, + workflow_id: workflow.id, + event: 'pull_request', + head_sha: sha, + status: 'success', + per_page: 100 + }); + const run = runs + .filter( + candidate => + candidate.workflow_id === workflow.id && + candidate.path === workflowPath && + candidate.event === 'pull_request' && + candidate.status === 'completed' && + candidate.conclusion === 'success' && + candidate.head_sha === sha && + candidate.head_branch === pull.head.ref && + candidate.repository?.id === pull.base.repo.id && + candidate.head_repository?.id === pull.head.repo.id && + // GitHub omits PR associations for fork runs. Repository, branch and SHA still bind the source. + (!candidate.pull_requests?.length || candidate.pull_requests.some(pr => pr.number === Number(prNumber))) + ) + .sort((a, b) => b.id - a.id)[0]; + if (!run) { + throw new Error( + `No successful PR bundle build for PR #${prNumber} at ${sha}. Wait for or re-run Bug Server PR Bundle.` + ); + } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: run.id, + per_page: 100 + }); + const matches = artifacts.filter(artifact => artifact.name === `bug-server-pr-${prNumber}-${sha}`); + if (matches.length !== 1) { + throw new Error(`Expected exactly one PR bundle artifact in run ${run.id}. Re-run Bug Server PR Bundle.`); + } + const artifact = matches[0]; + if (artifact.expired) { + throw new Error('The PR bundle artifact has expired. Re-run Bug Server PR Bundle.'); + } + if ( + artifact.workflow_run?.id !== run.id || + artifact.workflow_run.head_sha !== sha || + artifact.workflow_run.repository_id !== pull.base.repo.id || + artifact.workflow_run.head_repository_id !== pull.head.repo.id + ) { + throw new Error('Artifact provenance does not match the reviewed PR build.'); + } + + return { + prNumber: Number(prNumber), + sha, + headRef: pull.head.ref, + prUrl: pull.html_url, + runId: run.id, + runUrl: run.html_url, + artifactId: artifact.id + }; +} + +module.exports = { resolveBugServerTarget }; diff --git a/.github/scripts/bug-server-dispatch.test.cjs b/.github/scripts/bug-server-dispatch.test.cjs new file mode 100644 index 0000000000..789c0dd52d --- /dev/null +++ b/.github/scripts/bug-server-dispatch.test.cjs @@ -0,0 +1,285 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { resolveBugServerTarget } = require('./bug-server-dispatch.cjs'); + +const sha = '40be3619d1608aa1d5827f0a465704eeb036a7d3'; + +function fixture(overrides = {}) { + const calls = []; + const context = { + repo: { owner: 'VisActor', repo: 'VChart' }, + ref: 'refs/heads/develop', + payload: { repository: { default_branch: 'develop' } } + }; + const pull = { + base: { repo: { id: 1, full_name: 'VisActor/VChart' } }, + head: { sha, ref: 'feat/line-render-contribution', repo: { id: 2, full_name: 'g1f9/VChart' } }, + html_url: 'https://github.com/VisActor/VChart/pull/1234' + }; + const run = { + id: 100, + workflow_id: 50, + path: '.github/workflows/bug-server-pr-bundle.yml', + event: 'pull_request', + status: 'completed', + conclusion: 'success', + head_sha: sha, + head_branch: pull.head.ref, + repository: { id: 1 }, + head_repository: { id: 2 }, + pull_requests: [], + html_url: 'https://github.com/VisActor/VChart/actions/runs/100' + }; + const artifact = { + id: 200, + name: `bug-server-pr-1234-${sha}`, + expired: false, + workflow_run: { id: 100, repository_id: 1, head_repository_id: 2, head_sha: sha } + }; + const runs = [run]; + const artifacts = [artifact]; + const github = { + paginate: async (method, params) => { + const { data } = await method(params); + return data.workflow_runs ?? data.artifacts; + }, + rest: { + actions: { + getWorkflow: async params => { + assert.equal(params.workflow_id, 'bug-server-pr-bundle.yml'); + return { data: { id: 50, path: '.github/workflows/bug-server-pr-bundle.yml' } }; + }, + listWorkflowRuns: async params => { + assert.equal(params.workflow_id, 50); + assert.equal(params.head_sha, sha); + assert.equal(params.event, 'pull_request'); + assert.equal(params.status, 'success'); + return { data: { workflow_runs: runs } }; + }, + listWorkflowRunArtifacts: async params => { + assert.equal(params.run_id, 100); + return { data: { artifacts } }; + } + }, + pulls: { + get: async params => { + calls.push(params); + return { data: pull }; + } + } + } + }; + return { + args: { github, context, prNumber: '1234', headSha: sha, ...overrides }, + calls, + pull, + run, + artifact, + runs, + artifacts + }; +} + +test('resolves the reviewed fork head, including source metadata', async () => { + const { args, calls } = fixture({ headSha: sha.toUpperCase() }); + assert.deepEqual(await resolveBugServerTarget(args), { + prNumber: 1234, + sha, + headRef: 'feat/line-render-contribution', + prUrl: 'https://github.com/VisActor/VChart/pull/1234', + runId: 100, + runUrl: 'https://github.com/VisActor/VChart/actions/runs/100', + artifactId: 200 + }); + assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VChart', pull_number: 1234 }]); +}); + +for (const prNumber of ['', '0', '-1', '1.5', '1234;echo injected', '9007199254740992']) { + test(`rejects invalid PR number ${JSON.stringify(prNumber)} before API access`, async () => { + const { args, calls } = fixture({ prNumber }); + await assert.rejects(resolveBugServerTarget(args), /PR number/); + assert.equal(calls.length, 0); + }); +} + +for (const headSha of ['', '40be3619', 'g'.repeat(40), `${sha}\n`]) { + test(`rejects invalid SHA ${JSON.stringify(headSha)} before API access`, async () => { + const { args, calls } = fixture({ headSha }); + await assert.rejects(resolveBugServerTarget(args), /40-character/); + assert.equal(calls.length, 0); + }); +} + +test('rejects stale approval when the PR has a different head', async () => { + const { args, pull } = fixture(); + pull.head.sha = 'a'.repeat(40); + await assert.rejects(resolveBugServerTarget(args), /head changed/); +}); + +test('rejects a workflow launched from a non-default branch', async () => { + const { args, calls } = fixture(); + args.context.ref = 'refs/heads/feature'; + await assert.rejects(resolveBugServerTarget(args), /default branch/); + assert.equal(calls.length, 0); +}); + +test('rejects a PR belonging to another base repository', async () => { + const { args, pull } = fixture(); + pull.base.repo.full_name = 'someone/VChart'; + await assert.rejects(resolveBugServerTarget(args), /base repository/); +}); + +test('propagates API lookup failures without producing a build target', async () => { + const { args } = fixture(); + args.github.rest.pulls.get = async () => { + throw new Error('Not Found'); + }; + await assert.rejects(resolveBugServerTarget(args), /Not Found/); +}); + +for (const [name, change] of [ + [ + 'wrong workflow', + run => { + run.workflow_id = 51; + } + ], + [ + 'wrong workflow path', + run => { + run.path = '.github/workflows/other.yml'; + } + ], + [ + 'wrong event', + run => { + run.event = 'workflow_dispatch'; + } + ], + [ + 'wrong run SHA', + run => { + run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + run => { + run.repository.id = 3; + } + ], + [ + 'wrong head repository', + run => { + run.head_repository.id = 3; + } + ], + [ + 'wrong source branch', + run => { + run.head_branch = 'another-branch'; + } + ], + [ + 'wrong PR association', + run => { + run.pull_requests = [{ number: 2135 }]; + } + ], + [ + 'failed build', + run => { + run.conclusion = 'failure'; + } + ], + [ + 'unfinished build', + run => { + run.status = 'in_progress'; + } + ] +]) { + test(`rejects artifact source with ${name}`, async () => { + const { args, run } = fixture(); + change(run); + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); + }); +} + +test('accepts an explicit matching PR association', async () => { + const { args, run } = fixture(); + run.pull_requests = [{ number: 1234 }]; + assert.equal((await resolveBugServerTarget(args)).artifactId, 200); +}); + +test('rejects missing workflow runs', async () => { + const { args, runs } = fixture(); + runs.length = 0; + await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/); +}); + +test('does not select an older run instead of the latest matching run', async () => { + const { args, run, runs } = fixture(); + runs.unshift({ ...run, id: 99 }); + assert.equal((await resolveBugServerTarget(args)).runId, 100); +}); + +for (const [name, change] of [ + [ + 'expired', + artifact => { + artifact.expired = true; + } + ], + [ + 'wrong run', + artifact => { + artifact.workflow_run.id = 101; + } + ], + [ + 'wrong head SHA', + artifact => { + artifact.workflow_run.head_sha = 'a'.repeat(40); + } + ], + [ + 'wrong base repository', + artifact => { + artifact.workflow_run.repository_id = 3; + } + ], + [ + 'wrong head repository', + artifact => { + artifact.workflow_run.head_repository_id = 3; + } + ] +]) { + test(`rejects ${name} artifact`, async () => { + const { args, artifact } = fixture(); + change(artifact); + await assert.rejects(resolveBugServerTarget(args), /Artifact provenance|expired/); + }); +} + +test('rejects an artifact from a different PR or SHA', async () => { + const { args, artifact } = fixture(); + artifact.name = `bug-server-pr-2135-${sha}`; + await assert.rejects(resolveBugServerTarget(args), /exactly one/); +}); + +test('rejects missing or ambiguous artifacts', async () => { + for (const count of [0, 2]) { + const { args, artifacts, artifact } = fixture(); + artifacts.splice(0, 1, ...Array(count).fill(artifact)); + await assert.rejects(resolveBugServerTarget(args), /exactly one/); + } +}); + +test('does not silently fall back when the newest run artifact has expired', async () => { + const { args, run, runs, artifact } = fixture(); + runs.push({ ...run, id: 99 }); + artifact.expired = true; + await assert.rejects(resolveBugServerTarget(args), /expired/); +}); diff --git a/.github/scripts/extract_bug_server_bundle.py b/.github/scripts/extract_bug_server_bundle.py new file mode 100644 index 0000000000..43b51a7de6 --- /dev/null +++ b/.github/scripts/extract_bug_server_bundle.py @@ -0,0 +1,33 @@ +"""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): + 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 0000000000..893af70685 --- /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-pr-bundle.yml b/.github/workflows/bug-server-pr-bundle.yml new file mode 100644 index 0000000000..cad561b752 --- /dev/null +++ b/.github/workflows/bug-server-pr-bundle.yml @@ -0,0 +1,61 @@ +name: Bug Server PR Bundle + +on: + pull_request: + branches: ['main', 'develop', 'dev/**'] + +permissions: + contents: read + +jobs: + build-pr-bundle: + runs-on: macos-latest + timeout-minutes: 30 + steps: + # 仅在 pull_request 上下文执行 PR 代码,缓存写入限于 PR 作用域。 + # 此 workflow 不注入 Bug Server token。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Verify checkout + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - uses: actions/setup-node@v4 + with: + node-version: 18.x + - name: Install Python distutils + run: python3 -m pip install setuptools --break-system-packages + - name: Install native deps for node-canvas + run: | + brew update + brew install pkg-config cairo pango libpng jpeg giflib librsvg + - name: Install rush + run: node common/scripts/install-run-rush.js install --bypass-policy + - name: Build vutils-extension + working-directory: packages/vutils-extension + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: node ../../common/scripts/install-run-rushx.js build:es + - name: Build vchart + working-directory: packages/vchart + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: node ../../common/scripts/install-run-rushx.js build:es + - name: Build vchart-extension + working-directory: packages/vchart-extension + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: node ../../common/scripts/install-run-rushx.js build:es + - name: Build bugserver-trigger + working-directory: tools/bugserver-trigger + env: + NODE_OPTIONS: '--max_old_space_size=4096' + run: node ../../common/scripts/install-run-rushx.js build + - uses: actions/upload-artifact@v4 + with: + name: bug-server-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: tools/bugserver-trigger/dist/index.js + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/bug-server.yml b/.github/workflows/bug-server.yml index c23a0cb934..94636f9172 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -2,13 +2,27 @@ name: Bug Server CI # 这里业务方根据需求设置 on: + workflow_dispatch: + inputs: + pr_number: + description: '待验证的 PR 编号(支持外部 fork PR)' + required: true + type: string + head_sha: + description: '已审查的 PR head commit(完整 40 位 SHA)' + required: true + type: string push: branches: ['main'] pull_request: branches: ['main', 'develop', 'dev/**'] +permissions: + contents: read + jobs: build: + if: github.event_name != 'workflow_dispatch' runs-on: macos-latest strategy: @@ -17,14 +31,19 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' cache-dependency-path: './common/config/rush/pnpm-lock.yaml' + - name: Test manual dispatch validation + run: | + node --test .github/scripts/bug-server-dispatch.test.cjs + python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' + - name: Install Python distutils (macOS) if: runner.os == 'macOS' run: | @@ -87,3 +106,110 @@ jobs: env: BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} run: node ../../common/scripts/install-run-rushx.js ci + + resolve-manual-target: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + actions: read + outputs: + sha: ${{ steps.target.outputs.sha }} + pr_number: ${{ steps.target.outputs.pr_number }} + head_ref: ${{ steps.target.outputs.head_ref }} + pr_url: ${{ steps.target.outputs.pr_url }} + artifact_id: ${{ steps.target.outputs.artifact_id }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: .github/scripts + - name: Validate reviewed PR head + id: target + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ inputs.pr_number }} + HEAD_SHA: ${{ inputs.head_sha }} + with: + script: | + const { resolveBugServerTarget } = require('./.github/scripts/bug-server-dispatch.cjs'); + const target = await resolveBugServerTarget({ + github, context, + prNumber: process.env.PR_NUMBER, + headSha: process.env.HEAD_SHA + }); + core.setOutput('sha', target.sha); + core.setOutput('pr_number', target.prNumber); + core.setOutput('head_ref', target.headRef); + core.setOutput('pr_url', target.prUrl); + core.setOutput('artifact_id', target.artifactId); + await core.summary + .addHeading('Bug Server 手动验证') + .addLink(`PR #${target.prNumber}`, target.prUrl) + .addLink(`来源构建 ${target.runId}`, target.runUrl) + .addRaw(`\n\n被测 head: \`${target.sha}\`\n`) + .write(); + + submit-manual-bundle: + needs: resolve-manual-target + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + actions: read + steps: + # 使用默认分支 workflow 的固定提交,仅将 PR bundle 作为文件上传。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + sparse-checkout: | + .github/scripts + tools/bugserver-trigger/scripts + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install isolated trigger client dependencies + run: | + mkdir -p "$RUNNER_TEMP/bug-server-client" + npm install --prefix "$RUNNER_TEMP/bug-server-client" --ignore-scripts --no-audit --no-fund --package-lock=false \ + node-fetch@2.6.7 form-data@4.0.6 ts-node@10.9.0 typescript@4.9.5 + - name: Download reviewed PR artifact + uses: actions/github-script@v8 + env: + ARTIFACT_ID: ${{ needs.resolve-manual-target.outputs.artifact_id }} + with: + script: | + const archive = await github.rest.actions.downloadArtifact({ + ...context.repo, + artifact_id: Number(process.env.ARTIFACT_ID), + archive_format: 'zip' + }); + const fs = require('node:fs'); + const path = require('node:path'); + fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'bug-server-bundle.zip'), Buffer.from(archive.data)); + - name: Read bundle as data + run: python3 .github/scripts/extract_bug_server_bundle.py "$RUNNER_TEMP/bug-server-bundle.zip" tools/bugserver-trigger/dist/index.js + - name: Trigger Bug Server for reviewed PR + working-directory: tools/bugserver-trigger + env: + BUG_SERVER_TOKEN: ${{ secrets.BUG_SERVER_TOKEN }} + NODE_PATH: ${{ runner.temp }}/bug-server-client/node_modules + TEST_SHA: ${{ needs.resolve-manual-target.outputs.sha }} + TEST_REF: refs/pull/${{ needs.resolve-manual-target.outputs.pr_number }}/head + TEST_BRANCH: ${{ needs.resolve-manual-target.outputs.head_ref }} + TEST_PR_URL: ${{ needs.resolve-manual-target.outputs.pr_url }} + run: | + if [ -z "$BUG_SERVER_TOKEN" ]; then + echo '::error::BUG_SERVER_TOKEN is not configured for this repository.' + exit 1 + fi + test -f dist/index.js + printf 'PR: %s\n被测 head: `%s`\n' "$TEST_PR_URL" "$TEST_SHA" >> "$GITHUB_STEP_SUMMARY" + env GITHUB_SHA="$TEST_SHA" GITHUB_REF="$TEST_REF" GITHUB_HEAD_REF="$TEST_BRANCH" \ + node "$RUNNER_TEMP/bug-server-client/node_modules/ts-node/dist/bin.js" \ + --transpile-only --skip-project \ + --compiler-options '{"module":"CommonJS","moduleResolution":"node","esModuleInterop":true}' \ + scripts/trigger-test.ts diff --git a/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md new file mode 100644 index 0000000000..6d9f501c66 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -0,0 +1,53 @@ +# Bug Server 手动触发实现计划 + +> 参考 VRender `ae7fc0926` 的最终产物流程。使用方式见 `tools/bugserver-trigger/README.md`,权限边界见对应设计文档。 + +**目标:** 维护者输入 PR 编号和已审查的完整 head SHA,即可手动提交该 PR 的构建产物到 Bug Server。PR 构建仅在 `pull_request` 上下文执行。 + +**架构:** 独立 `pull_request` workflow 构建固定 head SHA 并上传产物;手动入口校验成功运行及产物来源,通过可信脚本读取单一 bundle 后交给原有客户端上传。 + +**技术栈:** GitHub Actions、Node.js 内置测试、Python 标准库、既有 Rush 构建和 TypeScript 客户端。 + +## 约束 + +- 保留 VChart 的 macOS / Node.js 18 四步构建、图片测试和性能测试行为。 +- 两个 workflow 默认 `contents: read`,仅查询/下载 job 增加所需的只读权限。 +- 默认分支手动 job 不检出或执行 PR 代码;PR 构建 runner 不持有 Bug Server token。 +- 产物名绑定 PR 编号与 SHA,保留 7 天;ZIP 只接受单一普通 `index.js`,最大 64 MiB,不按归档路径解压。 + +## 任务 1:来源与 ZIP 校验 + +文件:`.github/scripts/bug-server-dispatch.cjs`、对应 Node 测试、`extract_bug_server_bundle.py`、`test_extract_bug_server_bundle.py`。 + +- [x] 移植参考测试并将仓库与 PR fixture 改为 VChart;先运行确认新增路径失败。 +- [x] 同步 resolver:绑定 workflow、PR 事件、状态、仓库 ID、源分支、SHA,返回 `runId`、`runUrl`、`artifactId`。 +- [x] 同步 ZIP 读取器,拒绝额外文件、路径穿越、重复名称、链接、特殊文件、超大文件和覆盖已有文件。 +- [x] 运行 Node 和 Python 测试。 + +## 任务 2:调整 workflow + +文件:`.github/workflows/bug-server.yml`、`.github/workflows/bug-server-pr-bundle.yml`。 + +- [x] 在独立 PR workflow 中沿用 VChart 四步构建,固定检出 `github.event.pull_request.head.sha`,上传命名产物。 +- [x] 手动入口仅查询和提交产物:输出 artifact ID,按 ID 下载,再通过可信脚本读取 ZIP。 +- [x] 设置 workflow 默认只读权限,自动 CI 增加 Python 测试,保留 VChart 提交客户端依赖与 180 分钟超时。 +- [x] 修复 bugserver-trigger 入口 import 缺少 `from` 的语法错误。 +- [x] 为 bugserver-trigger 补齐共享 ESLint 配置,让提交钩子按 TypeScript 解析入口文件。 +- [x] 对两个 workflow 执行 actionlint,核对构建步骤及 token 边界。 + +## 任务 3:集成检查与文档 + +- [x] 更新中文使用说明和设计,注明先等待 PR bundle 成功、旧 PR 触发方式、产物过期处理及来源构建链接。 +- [x] 从最终 workflow 提取下载、ZIP 读取、提交命令,在隔离目录模拟 artifact 到客户端的完整数据流,确认产物仅作为字节上传。 +- [x] 检查格式及最终 diff,记录本地验证和线上未验证项。 + +## 验证结果 + +- Node 来源校验测试 36 项、Python ZIP 测试 6 项全部通过。 +- 本地 macOS / Node.js 24.19.0 使用现有依赖完成四步构建,生成约 5.1 MB 的 bundle,`node --check` 通过。CI 构建沿用 Node.js 18;本地未重新安装全部 Rush 依赖。构建保留已有 sourcemap、ES module this 和循环依赖告警。 +- actionlint 1.7.12 校验两个 workflow 通过;Prettier 和 `git diff --check` 通过。 +- 对比基线,自动触发条件、自动构建和 PR bundle 的四步 VChart 构建命令一致。两个 workflow 默认只读,PR 构建仅由 `pull_request` 触发、无 Bug Server secret;两个手动 job 固定检出 workflow SHA,无 PR 构建步骤。 +- 直接执行最终 workflow 的下载 JavaScript 和 ZIP 读取 shell:验证选定 artifact ID、正常字节保真;包含额外路径/客户端覆盖内容的 ZIP 被拒绝,可信客户端未被改写。 +- 读取所得的抛错 JavaScript bundle 交给隔离客户端 mock,成功、图片失败、SCM 失败、token 缺失四种场景通过;PR 元数据与 API 顺序保持正确,bundle 未执行。 +- resolver 和 ZIP 读取器与 VRender 最新实现相同;仅 workflow 的 runner、构建步骤、依赖版本、超时及中文说明保留 VChart 适配。 +- 本次未推送或运行 VChart 线上 Actions/CodeQL,也未调用真实 Bug Server。VRender 的线上验证记录不能替代 VChart 自身的线上验收。 diff --git a/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md new file mode 100644 index 0000000000..439817cca9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -0,0 +1,29 @@ +# Bug Server 手动触发设计 + +## 目标与方案 + +参考 VRender 的 `codex/bugserver-workflow-dispatch`,让维护者通过 PR 编号和已审查的完整 head SHA 验证外部 PR,无需创建临时分支或 PR。 + +同步参考分支 `ae7fc0926` 的最终方案:PR 事件提前构建产物,默认分支手动入口只校验和提交。此前的手动构建即使拆分 runner、不给 token、不配置缓存步骤,仍处于默认分支缓存可写的运行上下文。将构建移到 `pull_request` 事件,从事件作用域消除该风险,不采用参考仓库已放弃的 `cache-mode` 中间方案。 + +## 数据流 + +1. `bug-server-pr-bundle.yml` 仅由 `pull_request` 触发,检出准确 head SHA,沿用 VChart 的 macOS、Node.js 18 和 Rush 构建顺序。关闭持久 checkout 凭据、不传入 Bug Server token,缓存写入属于 PR 作用域。上传 `bug-server-pr-<编号>-`,保留 7 天。 +2. 仅从仓库默认分支运行 `workflow_dispatch`,从固定的 `github.workflow_sha` 加载校验脚本。校验 PR 编号、40 位十六进制 SHA、PR 所属仓库和当前 head。 +3. 通过 workflow ID/路径、PR 事件、成功状态、base/head 仓库 ID、源分支及 run SHA 绑定来源构建。fork 的运行记录可能没有 PR 列表;如列表存在则必须包含目标 PR。 +4. 选择最新匹配运行中唯一、未过期的命名产物,复核 artifact API 的 run ID、仓库 ID、SHA。不回退到较旧运行;失败时要求先成功运行 PR bundle workflow。 +5. 提交 runner 从 workflow SHA 加载可信脚本,按 artifact ID 下载 ZIP,只接受一个普通 `index.js` 文件,最大 64 MiB。只读取文件字节到固定目标,禁止路径解压、覆盖已有文件或执行产物。 +6. 固定版本客户端依赖独立安装且禁用 lifecycle scripts。仅触发 API 的 step 接收 `BUG_SERVER_TOKEN`,传入被测 SHA、PR ref、源分支,保留图片测试和性能测试行为。 +7. 两个 workflow 默认 `contents: read`;查询/下载 job 需要 `actions: read`,目标校验另需 `pull-requests: read`。summary 记录 PR、SHA 和来源构建,日志保留用例结果。既有 push / pull_request 自动构建与测试行为保留。 + +## 维护者操作 + +先等该 SHA 的 **Bug Server PR Bundle** 成功,再运行手动入口。产物缺失或过期需重跑 bundle 工作流。旧 PR 需更新或重新打开以触发新 PR 事件,外部 fork 的 Actions 运行可能需维护者批准。 + +## VChart 适配与验证 + +- 构建依次执行 vutils-extension、vchart、vchart-extension 的 `build:es`,最后构建 bugserver-trigger;不额外执行全量 schema/types/ES5 构建。 +- 修复 bugserver-trigger 入口中缺少 `from` 的 import,确保入口可构建。 +- 用 Node 测试覆盖目标解析及产物来源,Python 测试覆盖普通字节、路径穿越、额外/重复文件、链接/特殊文件、大小限制与目标覆盖;actionlint 校验两个 workflow。 +- VChart 构建命令已在首轮验证,此次不改构建命令。以 mock artifact/API 验证下载、可信 ZIP 读取、客户端上传、提交元数据和失败路径。 +- 真实手动运行需入口合入默认分支后执行,本次本地验证不调用真实 Bug Server。 diff --git a/tools/bugserver-trigger/.eslintrc.js b/tools/bugserver-trigger/.eslintrc.js new file mode 100644 index 0000000000..d93fedf675 --- /dev/null +++ b/tools/bugserver-trigger/.eslintrc.js @@ -0,0 +1,6 @@ +require('@rushstack/eslint-patch/modern-module-resolution'); + +module.exports = { + extends: ['@internal/eslint-config/profile/lib'], + parserOptions: { tsconfigRootDir: __dirname } +}; diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md new file mode 100644 index 0000000000..7035045ca1 --- /dev/null +++ b/tools/bugserver-trigger/README.md @@ -0,0 +1,53 @@ +# Bug Server CI + +`scripts/trigger-test.ts` 上传 `dist/index.js`,等待 SCM 构建,运行并等待图片测试,然后触发性能测试。调用接口需要仓库 secret `BUG_SERVER_TOKEN`。 + +## 手动验证外部 PR + +两个 workflow 合入仓库默认分支 `develop` 后,先等待该 PR head 的 **Bug Server PR Bundle** 构建成功。外部 fork 的运行可能需要维护者先批准。随后拥有仓库写权限的维护者可打开 **Actions → Bug Server CI → Run workflow**,选择 **develop** 并填写: + +- `pr_number`:VChart 仓库的 PR 编号,支持外部 fork PR。 +- `head_sha`:已审查的 PR head 的完整 40 位 SHA。 + +也可使用 GitHub CLI(替换下面的 PR 编号和 SHA): + +```sh +gh workflow run bug-server.yml \ + --repo VisActor/VChart \ + --ref develop \ + -f pr_number=1234 \ + -f head_sha='<已审查的完整 40 位 head SHA>' +``` + +可通过 `gh pr view 1234 --repo VisActor/VChart --json headRefOid --jq .headRefOid` 查看当前 head SHA。先审查该提交,再将 SHA 填入输入;如果 PR 更新,旧 SHA 会在校验阶段被拒绝。 + +被测代码是固定的 **PR head commit**。GitHub 自动生成的 merge commit 不参与此次构建,校验后的新推送也不会改变此次被测代码。构建失败时检查该 head 的安装和构建日志;手动流程不会修改 PR 代码或自动修补构建错误。 + +手动入口使用已经生成的 PR 产物,不再执行 PR 构建。产物保留 7 天;若构建或产物缺失、失败、过期,请先批准、等待或重跑 **Bug Server PR Bundle**。工作流引入前已打开的 PR,需要更新或重新打开 PR 以触发新的 PR 事件;重跑旧 workflow 定义不会生成新增的 bundle 工作流。 + +## 查看结果 + +- 在 Actions 中查看本次运行,summary 会记录 PR 链接、被测 SHA 和来源构建链接。 +- **Trigger Bug Server for reviewed PR** 日志提供 `scmVersion`、`bundleId`、图片用例总数和成功数,可用于在 Bug Server 定位结果。 +- token 未配置、SCM 构建失败、图片测试超时或失败都会导致 job 失败。性能测试沿用现有脚本行为:触发后不等待结果。 +- 手动运行不会自动给外部 PR 添加 check 或评论。 + +## 执行边界 + +1. **Bug Server PR Bundle** 仅由 `pull_request` 触发,在 macOS runner 上检出准确 head SHA,使用 Node.js 18 依次构建 vutils-extension、vchart、vchart-extension 和 bugserver-trigger。不持有 Bug Server token、不保留 checkout 凭据,缓存写入仅属于 PR 作用域;产物名为 `bug-server-pr-<编号>-`。 +2. 手动校验 job 从默认分支 workflow 的固定提交加载脚本,通过 GitHub API 确认当前 PR head,并校验来源 workflow ID/路径、PR 事件、成功状态、base/head 仓库 ID、源分支和 run SHA。选择最新匹配运行中唯一且未过期的命名产物,再复核产物的运行 ID、仓库 ID 和 SHA。fork 的运行记录可能没有 PR 列表,此时由仓库、分支、SHA 绑定来源;如列表存在,则还必须包含目标 PR。 +3. 提交 job 使用 workflow 固定提交中的可信脚本,按 artifact ID 下载 ZIP,只接受单一普通 `index.js` 文件,最大 64 MiB。读取器只把字节写入固定路径,不按 ZIP 路径解压。客户端依赖以 `--ignore-scripts` 独立安装;bundle 只作为文件上传,此 runner 不执行 PR bundle 或其 package scripts。 + +两个 workflow 默认 `contents: read`;手动查询和下载 job 另需 `actions: read`,目标校验还需 `pull-requests: read`。token 仅注入最后调用 Bug Server API 的 step。默认分支手动流程不检出或构建 PR 代码;仅省略缓存步骤无法消除默认分支缓存写权限,因此采用 PR 事件隔离构建。 + +已有 push / pull_request 自动入口保留。外部 fork 的自动运行仍然无法获得仓库 secret,使用上述手动入口完成验证。 + +## 本地检查 + +在仓库根目录执行: + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +python3 -B -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py' +actionlint .github/workflows/bug-server.yml .github/workflows/bug-server-pr-bundle.yml +``` diff --git a/tools/bugserver-trigger/src/index.ts b/tools/bugserver-trigger/src/index.ts index 8494d92410..03992d2254 100644 --- a/tools/bugserver-trigger/src/index.ts +++ b/tools/bugserver-trigger/src/index.ts @@ -1,6 +1,5 @@ import * as VChart from '@visactor/vchart'; -import * as VChartExtension '@visactor/vchart-extension'; - +import * as VChartExtension from '@visactor/vchart-extension'; VChart.Tooltip.builtInTheme.tooltip.transitionDuration = 0; @@ -8,6 +7,6 @@ VChart.Tooltip.builtInTheme.tooltip.transitionDuration = 0; (window as any).VChartExtension = VChartExtension; export default { - VChart, VChartExtension + VChart, + VChartExtension }; -