diff --git a/.github/scripts/bug-server-dispatch.cjs b/.github/scripts/bug-server-dispatch.cjs new file mode 100644 index 000000000..815be0bec --- /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 000000000..4a3e6473a --- /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: 'VRender' }, + ref: 'refs/heads/develop', + payload: { repository: { default_branch: 'develop' } } + }; + const pull = { + base: { repo: { id: 1, full_name: 'VisActor/VRender' } }, + head: { sha, ref: 'feat/line-render-contribution', repo: { id: 2, full_name: 'g1f9/VRender' } }, + html_url: 'https://github.com/VisActor/VRender/pull/2128' + }; + 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/VRender/actions/runs/100' + }; + const artifact = { + id: 200, + name: `bug-server-pr-2128-${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: '2128', 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: 2128, + sha, + headRef: 'feat/line-render-contribution', + prUrl: 'https://github.com/VisActor/VRender/pull/2128', + runId: 100, + runUrl: 'https://github.com/VisActor/VRender/actions/runs/100', + artifactId: 200 + }); + assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VRender', pull_number: 2128 }]); +}); + +for (const prNumber of ['', '0', '-1', '1.5', '2128;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/VRender'; + 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: 2128 }]; + 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 000000000..43b51a7de --- /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 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-pr-bundle.yml b/.github/workflows/bug-server-pr-bundle.yml new file mode 100644 index 000000000..fa86e3e01 --- /dev/null +++ b/.github/workflows/bug-server-pr-bundle.yml @@ -0,0 +1,42 @@ +name: Bug Server PR Bundle + +on: + pull_request: + branches: ['main', 'develop', 'dev/**'] + +permissions: + contents: read + +jobs: + build-pr-bundle: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # PR code runs only in the pull_request context; cache writes stay scoped to the PR. + # This workflow never receives the 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: 24.x + - name: Install native deps for node-canvas + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libcairo2-dev libpango1.0-dev libpng-dev libjpeg-dev libgif-dev librsvg2-dev + - name: Install and build PR bundle + run: | + node common/scripts/install-run-rush.js update --bypass-policy + node common/scripts/install-run-rush.js install --bypass-policy + node common/scripts/install-run-rush.js build -t @internal/bugserver-trigger + - 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 6f2002701..384fa2d81 100644 --- a/.github/workflows/bug-server.yml +++ b/.github/workflows/bug-server.yml @@ -2,17 +2,31 @@ name: Bug Server CI # 这里业务方根据需求设置 on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to test (including fork PRs)' + required: true + type: string + head_sha: + description: 'Reviewed PR head commit (full 40-character 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: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js 24.x uses: actions/setup-node@v4 with: @@ -20,6 +34,11 @@ jobs: 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: Print All Github Environment Variables run: env @@ -48,3 +67,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 manual test') + .addLink(`PR #${target.prNumber}`, target.prUrl) + .addLink(`Source build ${target.runId}`, target.runUrl) + .addRaw(`\n\nTested head: \`${target.sha}\`\n`) + .write(); + + submit-manual-bundle: + needs: resolve-manual-target + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + actions: read + steps: + # Use the immutable default-branch workflow commit, never scripts from the PR. + - 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.6 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\nTested 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 000000000..ced96a8ce --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-dispatch.md @@ -0,0 +1,56 @@ +# Bug Server Manual Dispatch Implementation Plan + +> Historical implementation record. The default-branch build described below has been replaced by the [PR artifact flow](2026-09-17-bug-server-pr-artifact.md); use the current README and design for maintenance. + +> Execute inline in this task; the workflow design was approved in the conversation. The referenced superpowers execution skills are not installed, so implementation uses the available repository tools. + +**Goal:** Allow maintainers to test an external PR at a reviewed SHA without creating a temporary PR. + +**Architecture:** Add manual-only validation, build, and submission jobs to the existing workflow. Keep the credential-bearing runner separate from PR code and use the workflow commit for trusted scripts. + +**Tech Stack:** GitHub Actions, actions/github-script, Node.js 24, existing TypeScript Bug Server client. + +## Global Constraints + +- Base branch: `develop`; implementation branch: `codex/bugserver-workflow-dispatch`. +- Inputs: `pr_number` and full `head_sha`. +- PR artifacts are data only in the submission job. +- Existing automatic workflows and Bug Server API protocol retain their behavior. + +## Task 1: Validate and resolve the manual target + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [x] Write Node tests for valid fork PRs, invalid PR numbers, malformed SHAs, stale SHAs, wrong base repository and non-default workflow branches. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs` and confirm the missing module fails. +- [x] Implement `resolveBugServerTarget({ github, context, prNumber, headSha })`, returning `{ prNumber, sha, headRef, prUrl }`. Validate locally before calling `github.rest.pulls.get`; compare the returned PR's repository and current head with the requested target. +- [x] Rerun the tests. + +## Task 2: Isolate build and submission + +Files: `.github/workflows/bug-server.yml`. + +- [x] Add the two string inputs, retain existing automatic build behind a non-dispatch condition, and add manual validation/build/submission jobs with read-only repository permissions. +- [x] Checkout PR code by the validated SHA, verify `git rev-parse HEAD`, build with the existing Rush commands, and upload only the generated bundle. +- [x] Checkout the trusted client by `github.workflow_sha` in the submission job. Install `node-fetch@2.6.6`, `form-data@4.0.6`, `ts-node@10.9.0`, and `typescript@4.9.5` outside the repository with lifecycle scripts disabled. +- [x] Run the client through the isolated ts-node executable with explicit CommonJS/esModuleInterop compiler options and reviewed PR metadata; expose the secret only for this command. +- [x] Run actionlint and simulate the trusted client against mocked API responses, verifying that the bundle is uploaded without execution. + +## Task 3: Document and verify + +Files: `tools/bugserver-trigger/README.md`. + +- [x] Document UI and CLI invocation, default-branch availability, tested head versus merge semantics, metadata, and result logs. +- [x] Review the final diff for secret exposure, event regressions and shell interpolation; run `git diff --check`. +- [x] Record completed checks and deliver the local branch. Do not claim a live Bug Server run before the default-branch workflow exists. + +## Verification results + +- Node validation tests: 15 passed; also wired into the automatic Bug Server CI job. +- actionlint 1.7.12: passed. Updated the existing checkout v3 to v4 because actionlint rejects its retired runtime. +- Executed the workflow submission shell block in an isolated directory using the exact dependency versions: mock success, photo-test failure and missing-token cases all passed. The mock verified PR metadata and received a bundle that throws if executed; it was only uploaded. +- `git diff --check`: passed. No live Bug Server call was made. + +## Security verification correction (2026-09-17) + +The original tests, mock integration and actionlint run verified functional behavior and workflow syntax, but did not verify cache permissions. Omitting a cache action does not remove the default-branch cache-write capability of a `workflow_dispatch` run. PR #2134 therefore adds explicit workflow-level read-only API permissions and job-level `cache-mode: none`, verified before PR checkout. See the [security fix plan](2026-09-17-bug-server-security-fix.md) for runtime evidence and scanner compatibility limitations. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md new file mode 100644 index 000000000..5cb1f2821 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-pr-artifact.md @@ -0,0 +1,61 @@ +# Bug Server PR Artifact Implementation Plan + +> Execute inline as the fallback in the approved security fix plan. The user reported that alert #45 still blocks the latest revision; this plan completes that repair without dismissing the alert. + +**Goal:** Remove PR code execution from the default-branch manual workflow and make the CodeQL security check pass. + +**Architecture:** A separate `pull_request` workflow builds the exact head with read-only repository permissions. The manual workflow retains PR number and reviewed SHA inputs, locates an immutable artifact using GitHub API provenance, and submits only its `index.js` bytes with the trusted client. No PR build scripts or downloaded code execute in the manual workflow. + +**Tech Stack:** GitHub Actions, Node.js tests, Python standard-library ZIP handling. + +## Constraints + +- Keep existing push / PR Bug Server behavior and the trusted client API protocol. +- Keep workflow-wide `contents: read`; grant `actions: read` only to manual artifact lookup/download jobs and `pull-requests: read` only to target validation. +- Remove the superseded `build-manual-bundle`, `cache-mode` and runtime-mode guard; isolation comes from the PR event's cache scope. +- Treat artifacts as untrusted bytes. Never extract archive paths or execute their content. +- Fork run API responses can have an empty `pull_requests` array: verified using PR #2128 run 35075478495. Bind provenance to workflow ID/path, event, repository IDs, source branch and exact run head SHA. If PR associations are present, they must include the requested PR. + +## Task 1: Resolve a PR artifact + +Files: `.github/scripts/bug-server-dispatch.cjs`, `.github/scripts/bug-server-dispatch.test.cjs`. + +- [x] Extend fixture tests to cover successful fork provenance, incorrect workflow/event/SHA/repository/branch/PR, unsuccessful builds, and missing/expired/ambiguous artifacts. Preserve all input validation tests. +- [x] Run `node --test .github/scripts/bug-server-dispatch.test.cjs`; confirm new tests fail before implementation. +- [x] Extend `resolveBugServerTarget({github, context, prNumber, headSha})` to return the existing target fields plus `{runId, runUrl, artifactId}`. Resolve `bug-server-pr-bundle.yml`, list successful PR runs for the reviewed SHA, select the latest matching run, and select exactly one non-expired artifact named `bug-server-pr-${prNumber}-${sha}` whose API provenance matches the run. +- [x] Run the tests again; all provenance rejection cases must pass. + +## Task 2: Move the build and safely consume the artifact + +Files: `.github/workflows/bug-server-pr-bundle.yml`, `.github/workflows/bug-server.yml`, `.github/scripts/extract_bug_server_bundle.py`, `.github/scripts/test_extract_bug_server_bundle.py`. + +- [x] Add a PR-only workflow for `main`, `develop`, `dev/**`, using checkout at `github.event.pull_request.head.sha`, disabled persisted credentials, Node 24, the existing native dependencies/Rush build and upload-artifact v4. Artifact retention: 7 days. No repository secrets or cache action. +- [x] Add ZIP tests for a valid binary bundle, executable text treated as bytes, path traversal, extra files, duplicate names, symlink entries, oversized payloads and existing output files. +- [x] Implement `extract_bundle(archive_path, destination)` with Python `zipfile`: require exactly one regular entry named `index.js`, limit the uncompressed bundle to 64 MiB, and write bytes to the explicit destination with exclusive creation. Do not call `extract` or `extractall`. +- [x] Remove the manual build job. Add trusted API artifact lookup outputs and download the selected artifact ID into a fixed temporary ZIP file. Run the trusted extraction script before the token-bearing submission step; keep the existing client command unchanged. +- [x] Run `python3 -m unittest discover -s .github/scripts -p 'test_extract_bug_server_bundle.py'`, the Node tests, actionlint on both workflows and `git diff --check`. + +## Task 3: Verify and document + +- [x] Push the update to PR #2134, check CodeQL alert #45 and #46 on the new commit, and require the CodeQL check to pass without dismissals. +- [x] Wait for the new PR-only bundle workflow to succeed. Invoke the trusted resolver against that real run, download its immutable artifact, verify single-file extraction, and verify the existing upload client with the local mock API. Do not execute the bundle. +- [x] Update README, design, the previous security plan, PR description and the existing Lark maintenance section. Document that maintainers wait for `Bug Server PR Bundle` before dispatch; missing/expired artifacts require a fresh successful PR bundle run. Existing PRs may need a new PR event after the workflow is merged. +- [x] Record separate results for security checks, artifact pipeline and the existing photo CI. End-to-end manual dispatch from the default branch remains a post-merge check. + +## Validation before push + +- Node resolver tests: 36 passed. +- Python archive tests: 6 tests passed, including multiple malicious-entry subcases. +- actionlint 1.7.12: both final workflows pass without ignored diagnostics. +- `git diff --check`: passed. + +## GitHub and integration validation + +Implementation commit: `ae7fc0926581218905a90a3838bfb5d65741f128`. + +- [CodeQL check](https://github.com/VisActor/VRender/runs/105095209881): `success`, no new alerts. Both Actions and JavaScript/TypeScript analyses passed. Alerts #45 and #46 have PR instance state `fixed`; neither was dismissed. +- [PR bundle run 35188318138](https://github.com/VisActor/VRender/actions/runs/35188318138): `success`. Runner initialization confirms `Contents: read` and `Metadata: read`. Its cache mode is `write` in the PR event's cache scope, not the default-branch scope. +- The production resolver and workflow download script were executed locally against the real GitHub API. They selected artifact `10483685493` from that run, bound to PR #2134 and the exact implementation SHA. +- The trusted ZIP reader produced a single 3,210,456-byte bundle, SHA-256 `43e2b49b5edbf3fc1bbc52759b6844ab6608848ec97da666322a510e73f2b79e`. The trusted upload client passed success, photo-failure and missing-token scenarios with a local mock API, which checked that the uploaded bundle bytes were preserved. A separate throwing-JavaScript fixture was also uploaded as data without execution. +- Required pre-push package tests passed. Existing automatic unit/photo CI runs were still running when this record was written; their results are separate from the verified artifact pipeline. +- README, design notes, superseded-plan notices, PR description and Lark maintenance document were updated to the artifact workflow. No merge or default-branch dispatch was performed. The first live manual Bug Server run remains a post-merge check. diff --git a/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md new file mode 100644 index 000000000..a9aa46155 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-bug-server-security-fix.md @@ -0,0 +1,209 @@ +# Bug Server 手动入口安全修复 Implementation Plan + +> **已被替代:** 本文保留第一轮 `cache-mode` 修复及验证记录。该方案未消除最新 CodeQL 告警,不再作为最终实现;当前方案见 [PR artifact 修复计划](2026-09-17-bug-server-pr-artifact.md)。默认分支手动流程现改为只消费 PR 工作流的产物,不执行 PR 构建代码。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> 当前环境未安装上述执行技能。用户已授权执行本计划,使用当前任务和仓库工具顺序完成;实际进度与证据记录在文末。 + +**Goal:** 修复 PR #2134 的默认分支缓存污染风险和自动构建 token 权限过大问题。 + +**Architecture:** 保留现有校验、构建、提交三个手动 jobs。通过 workflow 顶层 `permissions` 限制 GitHub API 权限,通过构建 job 的 `cache-mode` 独立限制缓存权限;这两类权限需要分别控制。 + +**Tech Stack:** GitHub Actions、Node.js 24、GitHub CodeQL、actionlint、GitHub CLI。 + +## Global Constraints + +- 审查基线:PR #2134,head `95514c67ed0c2bee3b1a45641596c168b48a05b2`。 +- 在现有 `codex/bugserver-workflow-dispatch` 分支追加修复,不重建功能或重写已有提交。 +- 保留输入 `pr_number`、`head_sha`、默认分支限制和固定 SHA 构建。 +- 保留独立 runner 和可信提交脚本;`BUG_SERVER_TOKEN` 仍只注入提交 step。 +- 只收紧本 workflow 权限;不修改仓库全局权限设置、发布流程和 Bug Server API。 +- 不用关闭扫描规则、隐藏告警或移除权限限制来让检查变绿。 +- 这是权限配置修复,不添加仅断言 YAML 文本的单测,也不重跑渲染库全量测试。 + +## 已核实的事实与方案选择 + +1. 仓库当前 `default_workflow_permissions` 为 `write`。旧 `build` job 没有显式权限,三个手动 jobs 已有只读权限。 +2. `workflow_dispatch` 在默认分支运行时默认拥有该分支的缓存写权限;不配置 cache action 不会撤销该权限。 +3. GitHub 官方文档支持 job 级别 `cache-mode: none`,由缓存 token 的作用域实施限制;只设置同名环境变量不等价。 +4. 当前最新版 actionlint 1.7.12 对该字段报 `unexpected key "cache-mode"`。已检查 CodeQL 主线的 `CachePoisoningQuery.qll`:其缓存写权限判断仍只看触发事件,没有考虑 `cache-mode`。 + +首选原生权限配置:改动集中,维护者使用方式不变。把 PR 构建搬到 `pull_request` 工作流、手动入口只消费其 artifact 也能隔离缓存,但需要新增运行记录和产物身份校验,不作为本次首选。仅增加 `permissions: contents: read` 无法修复缓存问题。 + +**兼容性处理原则:** GitHub 服务端与 runner 的实际支持需要先验证;不能承诺添加字段后现有 CodeQL 告警必然自动消失。扫描工具的兼容性问题与安全机制是否生效分别记录。 + +## Task 1:收紧两类权限 + +**Files:** +- Modify: `.github/workflows/bug-server.yml` + +**Interfaces:** +- Consumes: 原有事件、输入、job outputs、artifact 名称和提交脚本。 +- Produces: 所有 job 默认只有 `contents: read`;外部 PR 构建 job 没有缓存读写权限。 + +- [x] **1. 复核执行时的 PR head 和工作区,防止覆盖后续改动。** + +```sh +git status --short +gh pr view 2134 --repo VisActor/VRender --json headRefOid,headRefName,baseRefName +``` + +- [x] **2. 在 `on` 与 `jobs` 之间增加顶层权限。** + +```yaml +permissions: + contents: read +``` + +保留 `resolve-manual-target` 的 `contents: read`、`pull-requests: read`,以及另外两个手动 jobs 现有的 `contents: read`。旧 `build` 自动继承顶层只读权限;未声明的其他 API 权限不授予。 + +- [x] **3. 在 `build-manual-bundle` 中增加 job 级缓存限制。** + +```yaml + cache-mode: none +``` + +将原来的缓存注释替换为: + +```yaml + # PR code runs without Bug Server secrets or cache access. + # cache-mode controls cache tokens independently of GITHUB_TOKEN permissions. +``` + +- [x] **4. 在该 job 的 checkout 之前增加运行时检查。** + +```yaml + - name: Verify cache isolation + uses: actions/github-script@v8 + with: + script: | + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('PR builds require cache-mode: none.'); + } else { + core.info('Cache access is disabled for this job.'); + } +``` + +如果 runner 没有报告 `none`,立即停止,不能继续执行 PR 代码。该检查用于确认平台应用配置,实际权限边界仍是 job 级 `cache-mode`,不是环境变量本身。 + +执行中修正了检查载体:runner 的 `NodeScriptActionHandler` 会注入 `ACTIONS_CACHE_MODE`,普通 shell step 不会。不能用原计划的 shell 检查把变量未注入误判为平台不支持。 + +## Task 2:验证平台支持、扫描结果和功能 + +**Files:** +- Test: `.github/scripts/bug-server-dispatch.test.cjs`(已有测试,不修改) +- Temporary: `.github/workflows/bug-server-cache-policy-check.yml`(验收后删除) +- Modify: 本计划的验收记录 + +**Interfaces:** +- Consumes: Task 1 的 workflow 配置。 +- Produces: GitHub 原生解析与运行证据、权限日志、两条扫描告警的处理结果。 + +- [x] **1. 运行现有校验,记录 actionlint 版本及完整诊断。** + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +actionlint -version +actionlint .github/workflows/bug-server.yml +git diff --check +``` + +预期已有 15 项输入校验测试通过。若 actionlint 仍是 1.7.12,明确记录其对新字段的语法误报;不能把这次检查写成通过,也不能泛化忽略所有语法错误。其余诊断均需解决。 + +- [x] **2. 在 PR 分支运行不包含 PR 代码和 secret 的平台探针。** + +临时文件的完整内容: + +```yaml +name: Bug Server cache policy check +on: + push: + branches: [codex/bugserver-workflow-dispatch] +permissions: {} +jobs: + verify: + runs-on: ubuntu-latest + cache-mode: none + steps: + - name: Verify effective cache mode + uses: actions/github-script@v8 + with: + script: | + core.info(`Cache mode: ${process.env.ACTIONS_CACHE_MODE ?? 'unset'}`); + if (process.env.ACTIONS_CACHE_MODE !== 'none') { + core.setFailed('Expected cache-mode: none.'); + } +``` + +在修复实现进入正常提交、推送阶段时运行该探针。它不 checkout、不安装依赖、不调用 Bug Server、不读写缓存。验收要求 GitHub 接受 YAML,且日志输出 `cache mode: none`。保留 run URL,再删除临时 workflow。 + +如果 GitHub 拒绝字段或 runner 不报告 `none`,该方案不具备落地条件,应停止合并;不要删除隔离配置继续执行外部 PR。后续改用 `pull_request` 构建 artifact、手动入口校验其来源后上传的方案,并单独完成该架构的实现计划。 + +- [x] **3. 复查真实自动构建的权限和两条 CodeQL 告警。** + +```sh +gh pr checks 2134 --repo VisActor/VRender +gh api repos/VisActor/VRender/code-scanning/alerts/45 +gh api repos/VisActor/VRender/code-scanning/alerts/46 +``` + +在最新提交的 `build` job 的 Set up job 日志中,确认 `GITHUB_TOKEN Permissions` 没有写权限。确认缺失权限告警已修复;缓存告警若仍存在,核对该次分析使用的规则及提交 SHA。 + +对于尚未识别 `cache-mode` 的 CodeQL,记录官方权限语义、探针 run URL、实际 workflow 配置和规则源码证据。扫描仍失败时,不将 PR 描述成“全部检查通过”,不自动关闭告警;将残留扫描问题明确交付给维护者评审。若仓库合并规则要求该检查通过,解决工具识别问题或改用上述 PR artifact 方案后再合并,不绕过合并规则。 + +- [ ] **4. 合入默认分支后,用已完整审查的可信 PR head 做首次手动验收。** + +通过原有 Run workflow 表单输入该 PR 编号与完整 SHA。检查:校验通过、缓存隔离检查通过、固定 SHA 构建成功、artifact 上传/下载成功、可信脚本成功触发 Bug Server,summary 中 PR/SHA 正确。 + +这一步验证此前自动 PR CI 不会执行的三个手动 jobs。图片差异按 Bug Server 业务结果记录,与权限配置是否生效分别判断。验收失败时暂停手动入口的使用,修复后再为外部 PR 运行;不放宽权限作为回退。 + +## Task 3:同步维护说明并交付 + +**Files:** +- Modify: `tools/bugserver-trigger/README.md` +- Modify: `docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md` +- Append correction: `docs/superpowers/plans/2026-09-17-bug-server-dispatch.md` +- Update after verification: [VRender 日常维护文档](https://bytedance.larkoffice.com/wiki/RNbpwz9HZizi1WkQYcZcqnj6n92) + +**Interfaces:** +- Consumes: Task 2 的真实验证结果。 +- Produces: 与实现一致的权限说明和未完成项记录。 + +- [x] **1. 用具体权限说明替换含糊的“无共享缓存”。** + +README 的构建边界使用以下说明: + +> Build the reviewed PR with read-only repository access and `cache-mode: none`, which denies cache reads and writes independently of `GITHUB_TOKEN`. Verify the runner reports this mode before checking out PR code. Keep checkout credentials disabled and the Bug Server token on the separate submission runner. + +设计文档和飞书文档说明: + +> 手动构建 job 显式禁止缓存读写,执行 PR 代码前确认该设置生效。GitHub API 权限和缓存权限分别控制;不配置缓存步骤并不等于没有缓存权限。 + +在原实现计划的验证结果后补记:此前单测、mock 和 actionlint 通过仅覆盖功能及旧语法检查,未验证缓存权限隔离;此次修复补齐这项边界。 + +- [x] **2. 交付时列出改动和真实状态。** + +至少记录最新 commit、两条告警结果、自动构建权限日志、缓存模式探针 URL、首次真实手动运行结果。区分“合并前已验证”和“合并后待验证”,不把计划写成已完成结果。 + +## 参考依据 + +- [GitHub job 级 cache-mode](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idcache-mode) +- [GitHub 缓存权限与事件默认值](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode) +- [CodeQL 缓存写权限判断源码](https://github.com/github/codeql/blob/main/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll) +- [缓存污染告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878435) +- [缺失权限告警](https://github.com/VisActor/VRender/pull/2134#discussion_r4032878451) + +## 执行记录(2026-09-17) + +- 权限修复提交:`a1d30899c`;JavaScript action 检查修正:`f8fe1a07a`。 +- [首轮探针](https://github.com/VisActor/VRender/actions/runs/35179960262):GitHub 接受配置,初始化日志为 `Cache mode: none`,但 shell 没有该变量。通过官方 [NodeScriptActionHandler 源码](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs) 确认注入边界,改用 JavaScript action 检查。 +- [修正后的平台探针](https://github.com/VisActor/VRender/actions/runs/35180071512):**通过**。runner `2.337.0`;初始化日志及 JavaScript action 均报告 `Cache mode: none`。探针没有执行 PR 代码、接触 Bug Server secret 或读写缓存;验证后删除临时 workflow。 +- Node 输入校验:15/15 通过。直接运行 workflow 中的隔离检查脚本,确认 `none` 放行,`read`、`write`、未注入变量均拒绝,共 4 个场景通过。 +- 推送钩子要求的 `rush test --only tag:package` 已通过;没有以跳过钩子的方式推送。 +- actionlint 1.7.12:仅有 `cache-mode` 未识别诊断,**不记为通过**。GitHub 原生解析和 runner 验证通过。 +- CodeQL 权限告警 #46:实例状态为 **fixed**。缓存告警 #45 在 `a3d5f2e6e` 上仍为 **open**;其规则未考虑 `cache-mode`。没有忽略规则或关闭告警。 +- [自动构建启动日志](https://github.com/VisActor/VRender/actions/runs/35179962932/job/105069769361):`GITHUB_TOKEN Permissions` 仅有 `Contents: read`、`Metadata: read`,没有写权限。该 run 的构建步骤通过;后因新提交替代而取消,与另一旧提交的重复 CI 一同清理,最新提交的 CI 继续运行。 +- 已查询 develop 的传统 required status checks 和适用 rulesets:前者未启用,后者为空。未修改合并规则,也未合并 PR。 +- 首次真实手动链路仍需在修复合入默认分支后执行;平台探针不等于端到端 Bug Server 验收。 +- README、设计文档、原实现验证记录和飞书维护文档均已同步;飞书文档 revision 17 已回读确认。 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 000000000..603c95698 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-bug-server-dispatch-design.md @@ -0,0 +1,25 @@ +# Bug Server 手动触发设计 + +## 目标 + +维护者输入 PR 编号和已 review 的完整 head SHA,即可测试外部 PR。PR 构建在 `pull_request` 上下文中完成,默认分支的手动入口只校验和上传产物。 + +## 数据流与权限边界 + +1. `bug-server-pr-bundle.yml` 仅由 `pull_request` 触发。在只读仓库权限、无持久化 checkout 凭据、无 Bug Server token 的 runner 上检出准确 head SHA,执行 Rush 构建。缓存写入作用域属于该 PR,不属于默认分支。产物名为 `bug-server-pr--`,保留 7 天。 +2. `bug-server.yml` 的手动入口只允许默认分支,保留 `pr_number`、`head_sha`。可信脚本校验输入、base 仓库及 PR 当前 head,再从指定 PR bundle workflow 查找成功运行。 +3. 来源校验绑定 workflow ID/路径、事件、运行状态、base/head 仓库 ID、源分支及 run head SHA。fork 的运行记录可能没有 PR 列表,不能因此拒绝所有外部 PR;如列表存在则还需匹配 PR 编号。 +4. 选择最新匹配运行中唯一且未过期的命名产物,复核 artifact API 的 run ID、仓库 ID 与 SHA。失败时要求先成功运行 PR bundle 工作流,不回退到其他提交或较旧运行。 +5. 提交 job 只检出 `github.workflow_sha` 对应的可信脚本。通过 artifact ID 下载 ZIP,只接受一个名为 `index.js` 的普通文件,最大 64 MiB。可信 Python 脚本只把文件字节写入固定位置,不按 ZIP 路径解压,不执行产物。 +6. 可信 TypeScript 客户端的依赖独立安装且禁用 lifecycle scripts。仅最后的 API 调用 step 注入 `BUG_SERVER_TOKEN`;PR 元数据与产物来源由可信校验 job 提供。summary 记录 PR、SHA 和来源构建。 +7. 两个 workflow 默认 `contents: read`;查询 PR 需要 `pull-requests: read`,查询/下载 artifact 需要 `actions: read`。原有 push / pull_request 自动 Bug Server 步骤保持原来的构建和测试行为。 + +## 维护者操作变化 + +先等 `Bug Server PR Bundle` 对该 SHA 构建成功,再运行手动入口。产物缺失或过期时重跑 bundle 工作流。新增工作流之前的旧 PR 需要更新或重新打开以触发新 PR 事件;重跑旧定义不能生成新工作流。fork Actions 首次运行可能需要维护者批准。 + +## 验证 + +Node 测试覆盖输入与产物来源校验。Python 测试覆盖正常字节、可执行文本仅作为数据、路径穿越、额外文件、重复文件、链接/特殊文件、体积限制和禁止覆盖目标文件。actionlint 与 CodeQL 必须通过,不以关闭告警作为修复。真实 PR bundle 的查找、下载、读取及 mock 客户端上传在合并前验证;默认分支完整手动测试在合并后验收。 + +此前的 `cache-mode: none` 已在平台验证,但未完成扫描验收;最终方案移除默认分支内的 PR 构建,不再依赖该配置。 diff --git a/tools/bugserver-trigger/README.md b/tools/bugserver-trigger/README.md new file mode 100644 index 000000000..6eafaa8f0 --- /dev/null +++ b/tools/bugserver-trigger/README.md @@ -0,0 +1,46 @@ +# Bug Server CI + +`scripts/trigger-test.ts` uploads `dist/index.js`, waits for an SCM build, starts the Bug Server photo tests, and waits for their results. It requires `BUG_SERVER_TOKEN`. + +## Manually test a PR + +After the workflows are merged into the repository's default branch (`develop`), wait for **Bug Server PR Bundle** to succeed for the reviewed PR head. Fork runs may need a maintainer's approval. Then maintainers with repository write access can open **Actions → Bug Server CI → Run workflow**. Select **develop**, then enter: + +- `pr_number`: the PR number, including PRs from external forks. +- `head_sha`: the full 40-character SHA of the PR head that you reviewed. + +The equivalent CLI command is: + +```sh +gh workflow run bug-server.yml \ + --repo VisActor/VRender \ + --ref develop \ + -f pr_number=2128 \ + -f head_sha=40be3619d1608aa1d5827f0a465704eeb036a7d3 +``` + +Use the currently reviewed PR head; the example SHA becomes invalid if that PR changes. The workflow rejects non-default workflow branches, malformed inputs and a SHA that differs from the PR's current head. It builds the exact requested **head commit**, not GitHub's generated merge commit. Updates after validation cannot change the commit being built. + +The run appears under Actions; this manual run does not automatically attach a check or comment to the external PR. Its summary records the PR URL, tested head and source build run. The **Trigger Bug Server for reviewed PR** step prints `scmVersion`, `bundleId`, and the result counts, which identify the run in Bug Server. A missing token, failed SCM build or failed photo test makes the job fail. + +The manual entry consumes an existing PR bundle; it does not build PR code. Artifacts are retained for 7 days. If the build or artifact is missing, failed or expired, approve/wait for/re-run **Bug Server PR Bundle** before dispatching again. For a PR opened before this workflow was introduced, update or reopen the PR to trigger a new PR event; re-running an old workflow definition does not create the new bundle workflow. + +## Execution boundaries + +1. **Bug Server PR Bundle** runs only on `pull_request`, builds the exact head with read-only repository permissions, disabled persisted checkout credentials and no Bug Server token. Any cache writes are confined to the PR scope. It uploads `bug-server-pr--`. +2. The manual **resolve-manual-target** job validates the current PR head and source workflow ID/path, PR event, successful run, repository IDs, source branch and run SHA. It requires one non-expired artifact with matching GitHub API provenance. Fork runs can omit PR associations; the repository/branch/SHA checks still bind the source. +3. **submit-manual-bundle** uses scripts from the immutable default-branch workflow commit. It downloads the selected artifact ID and accepts only a single regular `index.js` entry, up to 64 MiB. The trusted extractor writes bytes to a fixed path without extracting archive paths. The client only uploads those bytes; it never executes the bundle or PR package scripts. + +Both workflows default to `contents: read`. Manual lookup and download jobs also need `actions: read`, and target validation needs `pull-requests: read`. The Bug Server token is injected only into the final API client step. Existing push and pull-request automatic runs retain their build and test behavior with read-only repository permissions. A fork PR's automatic Bug Server run still cannot obtain repository secrets; use the manual entry for Bug Server validation. + +The default-branch manual workflow does not check out or build PR code. This replaces the earlier `cache-mode` approach and does not require scanner exceptions. + +## Local validation + +From the repository root: + +```sh +node --test .github/scripts/bug-server-dispatch.test.cjs +python3 -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 +```