diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 47d8cae..c88ed14 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,10 +8,8 @@ on: type: boolean default: false workflow_run: - workflows: [PR Preview] + workflows: [PR Preview, PR Preview Sync] types: [completed] - pull_request_target: - types: [synchronize, converted_to_draft, closed, labeled, unlabeled, edited] permissions: contents: read @@ -23,11 +21,7 @@ concurrency: group: >- ${{ ( inputs.publication || github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' || - github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' || - github.event_name == 'pull_request_target' && - (github.event.action != 'closed' || !github.event.pull_request.merged) && - (!(github.event.action == 'labeled' || github.event.action == 'unlabeled') || github.event.label.name == 'benchmarks: skip') && - (github.event.action != 'edited' || github.event.changes.base) + github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' ) && 'pages-deployment' || format('pages-ignored-{0}-{1}', github.run_id, github.run_attempt) }} queue: max @@ -35,11 +29,7 @@ jobs: assemble: if: >- inputs.publication || github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' || - github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' || - github.event_name == 'pull_request_target' && - (github.event.action != 'closed' || !github.event.pull_request.merged) && - (!(github.event.action == 'labeled' || github.event.action == 'unlabeled') || github.event.label.name == 'benchmarks: skip') && - (github.event.action != 'edited' || github.event.changes.base) + github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest timeout-minutes: 30 outputs: diff --git a/.github/workflows/preview-sync.yml b/.github/workflows/preview-sync.yml new file mode 100644 index 0000000..44b141a --- /dev/null +++ b/.github/workflows/preview-sync.yml @@ -0,0 +1,18 @@ +name: PR Preview Sync + +on: + pull_request: + types: [synchronize, converted_to_draft, closed, labeled, unlabeled, edited] + +permissions: {} + +jobs: + notify: + if: >- + (github.event.action != 'closed' || !github.event.pull_request.merged) && + (!(github.event.action == 'labeled' || github.event.action == 'unlabeled') || github.event.label.name == 'benchmarks: skip') && + (github.event.action != 'edited' || github.event.changes.base) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - run: 'true' diff --git a/docs/README.md b/docs/README.md index bacf43e..00d3f94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,6 +102,7 @@ One GitHub Pages deployment contains separate HTML, JavaScript, and CSS builds: Previews show UI changes before merge, even when pending benchmark results are unavailable. After a PR is merged or closed, the next successful Pages deployment removes its preview. The preview URL then returns 404. +Pages skips a preview whose build lacks `index.html`, contains symbolic links, hidden or special files, or reserved directories, or exceeds 100 MB or 10,000 files. Production and other previews still deploy. ## CI Rules @@ -124,7 +125,7 @@ Benchmark checks ignore unrelated labels and title or body edits. * GitHub Actions must use **Require approval for all external contributors**. **Approve and run** authorizes pending benchmark archival and serving preview JavaScript from that revision. Collaborators run automatically. * PR jobs use read-only repository permissions. The separate `workflow_run` publishers execute `main` code with their own tokens. * Benchmark Publication uses `contents: write` and `statuses: write`. It commits to `data` as `github-actions[bot]` and restricts write paths in code. -* Pages handles PR metadata with `pull_request_target` and executes only `main` code. +* Pages runs only `main` code. PR Preview and PR Preview Sync trigger it through `workflow_run`. * Pages uses `pages: write` and `id-token: write`. Configure Pages for GitHub Actions and restrict the `github-pages` environment to `main`. * Publishers validate PR artifacts as untrusted input. Artifact validation and a successful workflow do not prove measurements are genuine. Publishers do not execute artifact scripts or restore PR caches. * PRs can change workflows and check scripts. Inspect those changes before allowing execution or merging. Self-hosted runners must be disposable and isolated from credentials and sensitive networks. diff --git a/scripts/assemble-site.ts b/scripts/assemble-site.ts index d889977..a7b6c0a 100644 --- a/scripts/assemble-site.ts +++ b/scripts/assemble-site.ts @@ -47,12 +47,13 @@ export function assembleSite(options: { output: string, repository: string, previews: (z.infer & { directory: string, includePending: boolean })[], -}) { +}): number[] { if (existsSync(options.output)) throw new Error('Site output must be a new directory') const index = historyIndexSchema.parse(readOptionalJson(resolve(options.store, 'index.json')) ?? { schemaVersion: 1, runs: [] }) if (index.preview) throw new Error('Production history must not contain preview metadata') const published = index.runs.map(entry => parseHistoryRun(readJson(resolve(options.store, historyRunPath(entry))), entry)) const seen = new Set() + const included: number[] = [] function serveData(destination: string, bundles: RunBundle[], preview?: { number: number, head: string, pending: { id: string, attempt: number } | null }) { const catalogs = new Map(groupHistories(bundles).flatMap(history => { const { runtimes, platforms, metrics } = validateHistory(history) @@ -99,11 +100,21 @@ export function assembleSite(options: { } const destination = resolve(options.output, 'previews', `pr-${preview.number}`) mkdirSync(resolve(options.output, 'previews'), { recursive: true }) - copyBuild(preview.directory, destination) + try { + copyBuild(preview.directory, destination) + } catch (error) { + rmSync(destination, { recursive: true, force: true }) + // Escaping stops artifact file names from injecting workflow commands. + const message = String((error as Error).message).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A') + console.log(`::warning::PR ${preview.number} preview skipped: ${message}`) + continue + } serveData(destination, bundles, { number: preview.number, head: preview.head, pending }) + included.push(preview.number) } } catch (error) { rmSync(options.output, { recursive: true, force: true }) throw error } + return included } \ No newline at end of file diff --git a/scripts/publication.test.ts b/scripts/publication.test.ts index 0a3ad86..207101d 100644 --- a/scripts/publication.test.ts +++ b/scripts/publication.test.ts @@ -181,8 +181,9 @@ test('Pages assembly keeps pending data inside its preview', async context => { assert.equal(existsSync(resolve(closedOutput, 'previews/pr-7')), false) symlinkSync(resolve(production, 'index.html'), resolve(previewDirectory, 'linked.html')) const unsafeOutput = resolve(temporary, 'unsafe') - assert.throws(() => assembleSite({ production, store, output: unsafeOutput, repository, previews: [preview] }), /symbolic link/) - assert.equal(existsSync(unsafeOutput), false) + assert.deepEqual(assembleSite({ production, store, output: unsafeOutput, repository, previews: [preview] }), []) + assert.equal(existsSync(resolve(unsafeOutput, 'index.html')), true) + assert.equal(existsSync(resolve(unsafeOutput, 'previews/pr-7')), false) }) function fixture() { diff --git a/scripts/site-ci.ts b/scripts/site-ci.ts index 23487a4..3541542 100644 --- a/scripts/site-ci.ts +++ b/scripts/site-ci.ts @@ -38,7 +38,10 @@ async function verifiedBuild(run: any, pr: any, artifact: any): Promise || run.head_sha !== pr.head.sha || run.head_repository?.full_name !== pr.head.repo.full_name || !run.pull_requests.some((entry: { number: number }) => entry.number === pr.number)) return false if (!artifact || artifact.expired || artifact.name !== `preview-${pr.number}-${pr.head.sha}-${run.run_attempt}`) return false - if (artifact.size_in_bytes > 100 * 1024 ** 2) throw new Error(`Preview artifact is too large: ${artifact.name}`) + if (artifact.size_in_bytes > 100 * 1024 ** 2) { + console.log(`::warning::PR ${pr.number} preview skipped: artifact is too large`) + return false + } const jobs = await github(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`) for (const jobName of ['revision', 'build']) { const matches = jobs.jobs.filter((job: { name: string }) => job.name === jobName) @@ -116,7 +119,8 @@ if (mode === 'verify') { '--name', `preview-${preview.number}-${preview.head}-${preview.attempt}`, '--dir', directory]) previews.push({ ...preview, directory }) } - assembleSite({ production: resolve('dist'), store, output: resolve('site'), repository, previews }) + const included = assembleSite({ production: resolve('dist'), store, output: resolve('site'), repository, previews }) + desired.previews = desired.previews.filter(preview => included.includes(preview.number)) try { await verify(desired) } catch (error) { @@ -124,5 +128,5 @@ if (mode === 'verify') { throw error } writeJson('site-state.json', desired, true) - console.log(`Assembled production and ${previews.length} current PR previews`) + console.log(`Assembled production and ${desired.previews.length} current PR previews`) } \ No newline at end of file