Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,23 +21,15 @@ 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

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:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/preview-sync.yml
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions scripts/assemble-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ export function assembleSite(options: {
output: string,
repository: string,
previews: (z.infer<typeof previewBuildSchema> & { 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<number>()
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)
Expand Down Expand Up @@ -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
}
5 changes: 3 additions & 2 deletions scripts/publication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
10 changes: 7 additions & 3 deletions scripts/site-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ async function verifiedBuild(run: any, pr: any, artifact: any): Promise<boolean>
|| 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)
Expand Down Expand Up @@ -116,13 +119,14 @@ 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) {
rmSync('site', { recursive: true })
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`)
}
Loading