From bb29e96ef64ca454d44cc36b48ac84f74bd2a5c6 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Mon, 12 Oct 2026 20:13:01 +0000 Subject: [PATCH 1/2] Add Pull Request Previews Reuse the web build from pull request CI to publish previews to Cloudflare Pages. Give each pull request a stable branch URL and link it from a Web preview check on the tested commit. Keep the hosting account and project configurable so the same workflows also work in forks. Separate building from publishing so pull request code never receives the deployment token. Resolve the pull request and artifact through GitHub's API, publish only the current open head after successful CI, and execute the publisher from the default branch. Validate the static files and run Wrangler in an isolated directory to prevent uploaded Workers, package scripts, or configuration from running with deployment credentials. Use native TypeScript helpers and include them in linting and type checking. Build optimized previews with the usual WebAssembly and font preloading, but leave out the production service worker so updates appear immediately. Allow previews to build when contributor credits cannot be fetched. Keep each pull request on its own origin so its saved splits and layouts remain separate from production and other previews while surviving preview updates. Delete every deployment for a pull request when it closes or merges, including older builds and the branch alias. Gather all deployment pages before deleting, protect production and other pull requests, and retire the preview check after cleanup. Serialize publishing and cleanup for each pull request, recheck its current state, and handle closed pull requests before downloading artifacts so a late publisher can still clean up after an expired artifact or a replaced pending close job. --- .github/scripts/preview-cloudflare.mts | 147 ++++++++++++ .github/scripts/preview-cloudflare.test.mts | 197 ++++++++++++++++ .github/scripts/preview-github.mts | 196 ++++++++++++++++ .github/scripts/preview-github.test.mts | 225 +++++++++++++++++++ .github/workflows/ci.yml | 20 +- .github/workflows/preview-cleanup.yml | 41 ++++ .github/workflows/preview.yml | 142 ++++++++++++ eslint.config.ts | 1 + package-lock.json | 237 ++++++++++++++++++++ package.json | 5 +- tsconfig.node.json | 3 + vite.config.ts | 14 +- 12 files changed, 1221 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/preview-cloudflare.mts create mode 100644 .github/scripts/preview-cloudflare.test.mts create mode 100644 .github/scripts/preview-github.mts create mode 100644 .github/scripts/preview-github.test.mts create mode 100644 .github/workflows/preview-cleanup.yml create mode 100644 .github/workflows/preview.yml diff --git a/.github/scripts/preview-cloudflare.mts b/.github/scripts/preview-cloudflare.mts new file mode 100644 index 00000000..668fa4a6 --- /dev/null +++ b/.github/scripts/preview-cloudflare.mts @@ -0,0 +1,147 @@ +import { + markPreviewRemoved, + previewBranch, + type GitHubClient, + type Repository, +} from "./preview-github.mts"; + +type ApiResponse = { + success: boolean; + result: T; + result_info?: { total_pages?: number }; +}; +export type PagesClient = { + get(suffix?: string): Promise>; + delete(suffix: string): Promise; +}; +type Deployment = { + id: string; + environment: string; + deployment_trigger?: { metadata?: { branch?: string } }; +}; + +export function cloudflareClient( + account: string, + project: string, + token: string, + fetcher: typeof fetch = fetch, +): PagesClient { + if ( + !/^[0-9a-f]{32}$/i.test(account) || + !/^[a-z0-9][a-z0-9-]{0,62}$/.test(project) || + !token + ) { + throw new Error( + "Configure the Cloudflare account, preview project, and API token.", + ); + } + const base = `https://api.cloudflare.com/client/v4/accounts/${account}/pages/projects/${project}`; + async function request(suffix = "", method = "GET") { + const response = await fetcher(base + suffix, { + method, + headers: { Authorization: `Bearer ${token}` }, + }); + // Deletions are idempotent, including retries after a partial cleanup. + if (method === "DELETE" && response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error( + `Cloudflare request failed: HTTP ${response.status}.`, + ); + } + const body = (await response.json()) as ApiResponse; + if (!body.success) { + throw new Error("Cloudflare rejected the preview request."); + } + return body; + } + return { + async get(suffix = "") { + const response = await request(suffix); + if (response === null) + throw new Error("Missing Cloudflare response."); + return response as ApiResponse; + }, + async delete(suffix) { + await request(suffix, "DELETE"); + }, + }; +} + +export async function checkPreviewProject(client: PagesClient, number: number) { + const branch = previewBranch(number); + const { result } = await client.get<{ production_branch: string }>(); + // A mistaken project configuration must never turn a PR deployment into + // production, where deleting its latest deployment would be unsafe. + if ( + typeof result.production_branch !== "string" || + /^pr-\d+$/.test(result.production_branch) + ) { + throw new Error( + "The Pages production branch must not use a PR preview branch.", + ); + } + return branch; +} + +export async function deletePreviewDeployments( + client: PagesClient, + number: number, +) { + const branch = await checkPreviewProject(client, number); + const deployments: Deployment[] = []; + // Collect every page before deleting anything. Otherwise deletions shift + // the remaining pages and can leave older PR builds behind. + for (let page = 1; ; page++) { + const response = await client.get( + `/deployments?env=preview&per_page=100&page=${page}`, + ); + deployments.push( + ...response.result.filter( + (deployment) => + deployment.environment === "preview" && + deployment.deployment_trigger?.metadata?.branch === branch, + ), + ); + const totalPages = response.result_info?.total_pages; + if ( + totalPages !== undefined + ? page >= totalPages + : response.result.length < 100 + ) { + break; + } + } + for (const deployment of deployments) { + // force removes the branch alias as well as historic immutable URLs. + // The explicit environment and branch checks above protect production + // and every other PR, even if the API returns an unexpected entry. + await client.delete( + `/deployments/${encodeURIComponent(deployment.id)}?force=true`, + ); + } + return deployments.length; +} + +// Both the close workflow and late publishers use the same cleanup. GitHub +// replaces older pending jobs in a concurrency group, even with cancellation +// disabled. A publisher queued after a close must therefore clean up too, +// rather than rely solely on the potentially replaced close job. +export async function removeClosedPreview( + github: GitHubClient, + repo: Repository, + number: number, + client: PagesClient, +) { + const { data: pr } = await github.rest.pulls.get({ + ...repo, + pull_number: number, + }); + if (pr.state !== "closed") { + return null; + } + const deleted = await deletePreviewDeployments(client, number); + await markPreviewRemoved(github, repo, number, pr.head.sha); + return deleted; +} diff --git a/.github/scripts/preview-cloudflare.test.mts b/.github/scripts/preview-cloudflare.test.mts new file mode 100644 index 00000000..c618c1c3 --- /dev/null +++ b/.github/scripts/preview-cloudflare.test.mts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Octokit } from "@octokit/rest"; +import { + cloudflareClient, + deletePreviewDeployments, + removeClosedPreview, +} from "./preview-cloudflare.mts"; + +const account = "a".repeat(32); + +test("merged and unmerged closes clean up; a reopened PR keeps its preview", async () => { + for (const [state, merged] of [ + ["closed", true], + ["closed", false], + ["open", false], + ] as const) { + const github = new Octokit({ + request: { + fetch: (input: RequestInfo | URL) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + const data = url.pathname.endsWith("/pulls/42") + ? { state, merged, head: { sha: "a".repeat(40) } } + : { total_count: 0, check_runs: [] }; + const response = new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }); + Object.defineProperty(response, "url", { + value: url.toString(), + }); + return Promise.resolve(response); + }, + }, + }); + let removed = 0; + const client = cloudflareClient( + account, + "lso-previews", + "test-token", + (input, init) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + if (init?.method === "DELETE") removed++; + const result = url.pathname.includes("deployments") + ? [deployment("latest", "pr-42")] + : { production_branch: "master" }; + return Promise.resolve( + new Response(JSON.stringify({ success: true, result })), + ); + }, + ); + assert.equal( + await removeClosedPreview( + github, + { owner: "LiveSplit", repo: "LiveSplitOne" }, + 42, + client, + ), + state === "closed" ? 1 : null, + ); + assert.equal(removed, state === "closed" ? 1 : 0); + } +}); + +function deployment(id: string, branch: string, environment = "preview") { + return { id, environment, deployment_trigger: { metadata: { branch } } }; +} + +test("removes every PR build and its alias without touching other PRs or production", async () => { + const calls: string[] = []; + const fetcher: typeof fetch = (input, init) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + const call = `${init?.method ?? "GET"} ${url.pathname}${url.search}`; + calls.push(call); + let result: unknown; + let pages: { total_pages: number } | undefined; + if (!url.pathname.includes("/deployments")) { + result = { production_branch: "master" }; + } else if (init?.method === "DELETE") { + assert.equal(url.searchParams.get("force"), "true"); + result = null; + } else { + assert.equal(url.searchParams.get("env"), "preview"); + pages = { total_pages: 2 }; + result = + url.searchParams.get("page") === "1" + ? [ + deployment("latest", "pr-42"), + deployment("other", "pr-43"), + deployment("production", "pr-42", "production"), + ] + : [ + deployment("old", "pr-42"), + deployment("similar", "pr-420"), + ]; + } + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + result, + result_info: pages, + }), + { headers: { "content-type": "application/json" } }, + ), + ); + }; + const client = cloudflareClient( + account, + "lso-previews", + "test-token", + fetcher, + ); + assert.equal(await deletePreviewDeployments(client, 42), 2); + assert(calls[1]?.includes("page=1") && calls[2]?.includes("page=2")); + const removed = calls.filter((call) => call.startsWith("DELETE")); + assert.equal(removed.length, 2); + assert( + removed[0]?.includes("/latest?force=true") && + removed[1]?.includes("/old?force=true"), + ); +}); + +test("empty and already-deleted previews are safe to clean up repeatedly", async () => { + for (const entries of [[], [deployment("gone", "pr-42")]]) { + const client = cloudflareClient( + account, + "lso-previews", + "test-token", + (input, init) => { + if (init?.method === "DELETE") + return Promise.resolve(new Response(null, { status: 404 })); + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + result: url.pathname.includes("deployments") + ? entries + : { production_branch: "master" }, + }), + ), + ); + }, + ); + assert.equal( + await deletePreviewDeployments(client, 42), + entries.length, + ); + } +}); + +test("rejects a misconfigured production branch before listing or deleting deployments", async () => { + let requests = 0; + const client = cloudflareClient( + account, + "lso-previews", + "test-token", + () => { + requests++; + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + result: { production_branch: "pr-42" }, + }), + ), + ); + }, + ); + await assert.rejects( + deletePreviewDeployments(client, 42), + /production branch/, + ); + assert.equal(requests, 1); +}); + +test("authentication errors fail cleanup without logging the token or response body", async () => { + const token = "private-test-token"; + const client = cloudflareClient(account, "lso-previews", token, () => + Promise.resolve(new Response(token, { status: 401 })), + ); + await assert.rejects( + deletePreviewDeployments(client, 42), + (error: unknown) => + error instanceof Error && + error.message.includes("401") && + !error.message.includes(token), + ); +}); diff --git a/.github/scripts/preview-github.mts b/.github/scripts/preview-github.mts new file mode 100644 index 00000000..a1b27f80 --- /dev/null +++ b/.github/scripts/preview-github.mts @@ -0,0 +1,196 @@ +import { lstat, readdir } from "node:fs/promises"; +import path from "node:path"; +import type { Octokit } from "@octokit/rest"; + +export type GitHubClient = Pick; +export type Repository = { owner: string; repo: string }; +export type SourceRun = { + repository: { full_name: string }; + event: string; + conclusion: string | null; + workflow_id: number; + head_sha: string; + id: number; +}; +type PullRequest = { + state: string; + head: { sha: string }; + base: { repo: { full_name: string } }; +}; + +export const ARTIFACT_NAME = "livesplit-one-preview"; + +export function previewBranch(number: number): string { + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error("Invalid pull request number."); + } + return `pr-${number}`; +} + +export function isCurrentPullRequest( + pr: PullRequest, + sha: string, + repository: string, +): boolean { + return ( + pr.state === "open" && + pr.head.sha === sha && + pr.base.repo.full_name === repository + ); +} + +// Determine identity exclusively through GitHub's API, never through files +// uploaded by PR code. workflow_run.pull_requests can be empty for fork PRs, +// so resolve associated PRs through the commit API in that case as well. +export async function findPreview( + github: GitHubClient, + run: SourceRun, + repo: Repository, +) { + const repository = `${repo.owner}/${repo.repo}`; + if ( + run.repository.full_name !== repository || + run.event !== "pull_request" || + run.conclusion !== "success" + ) { + return null; + } + const { data: workflow } = await github.rest.actions.getWorkflow({ + ...repo, + workflow_id: run.workflow_id, + }); + if (workflow.path !== ".github/workflows/ci.yml") { + return null; + } + const prs = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { ...repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const candidates = prs.filter((pr) => + isCurrentPullRequest(pr, run.head_sha, repository), + ); + // Never guess which preview to replace if several open PRs share a head. + const candidate = candidates[0]; + if (candidates.length !== 1 || !candidate) { + return null; + } + const { data: pr } = await github.rest.pulls.get({ + ...repo, + pull_number: candidate.number, + }); + if (!isCurrentPullRequest(pr, run.head_sha, repository)) { + return null; + } + previewBranch(pr.number); + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { ...repo, run_id: run.id, per_page: 100 }, + ); + const sites = artifacts.filter( + (artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired, + ); + const site = sites[0]; + if (sites.length !== 1 || !site) { + return null; + } + return { number: pr.number, sha: run.head_sha, artifact: site.id }; +} + +// These files will be uploaded, never executed. Reject advanced-mode Workers, +// links to files outside the artifact, and assets exceeding Pages' free limits. +// Wrangler runs in a separate empty directory, so artifact-supplied config, +// package scripts, or a functions directory cannot run with deployment tokens. +export async function validateStaticSite(directory: string): Promise { + let files = 0; + async function visit(relative: string): Promise { + const entry = await lstat(path.join(directory, relative)); + if (entry.isSymbolicLink()) { + throw new Error("Preview assets must not contain symbolic links."); + } + if (relative === "_worker.js") { + throw new Error( + "PR previews must be static sites, not Pages Workers.", + ); + } + if (entry.isDirectory()) { + for (const name of await readdir(path.join(directory, relative))) { + await visit(path.join(relative, name)); + } + } else if (!entry.isFile() || entry.size > 25 * 1024 * 1024) { + throw new Error( + "Invalid preview asset or asset larger than 25 MiB.", + ); + } else if (++files > 20000) { + throw new Error("Preview exceeds the free Pages file limit."); + } + } + await visit(""); + if (!(await lstat(path.join(directory, "index.html"))).isFile()) { + throw new Error("The preview must include index.html."); + } +} + +export async function reportPreview( + github: GitHubClient, + repo: Repository, + number: number, + sha: string, + url: string, +) { + // Stable branch aliases preserve preview browser storage across commits. + // Each PR has its own origin, separate from production and other PRs. + const parsed = new URL(url); + if ( + parsed.protocol !== "https:" || + !parsed.hostname.startsWith(`${previewBranch(number)}.`) || + !parsed.hostname.endsWith(".pages.dev") || + parsed.username || + parsed.password + ) { + throw new Error("Unexpected Cloudflare preview URL."); + } + await github.rest.checks.create({ + ...repo, + name: "Web preview", + head_sha: sha, + status: "completed", + conclusion: "success", + details_url: url, + output: { + title: "Open the LiveSplit One preview", + summary: `[View preview](${url}) — automatically removed when this PR closes or merges.`, + }, + }); +} + +export async function markPreviewRemoved( + github: GitHubClient, + repo: Repository, + number: number, + sha: string, +) { + previewBranch(number); + const checks = await github.paginate(github.rest.checks.listForRef, { + ...repo, + ref: sha, + check_name: "Web preview", + per_page: 100, + }); + for (const check of checks) { + // Avoid modifying another integration's checks with the same name. + if (check.app?.slug === "github-actions") { + await github.rest.checks.update({ + ...repo, + check_run_id: check.id, + status: "completed", + conclusion: "neutral", + details_url: `https://github.com/${repo.owner}/${repo.repo}/pull/${number}`, + output: { + title: "Preview removed", + summary: + "This PR has closed or merged, so its preview deployments were deleted.", + }, + }); + } + } +} diff --git a/.github/scripts/preview-github.test.mts b/.github/scripts/preview-github.test.mts new file mode 100644 index 00000000..8f60e96f --- /dev/null +++ b/.github/scripts/preview-github.test.mts @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm, symlink, open } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { Octokit } from "@octokit/rest"; +import { + findPreview, + isCurrentPullRequest, + markPreviewRemoved, + previewBranch, + reportPreview, + validateStaticSite, + type SourceRun, +} from "./preview-github.mts"; + +const repo = { owner: "LiveSplit", repo: "LiveSplitOne" }; +const sha = "a".repeat(40); +const run: SourceRun = { + repository: { full_name: "LiveSplit/LiveSplitOne" }, + event: "pull_request", + conclusion: "success", + workflow_id: 1, + head_sha: sha, + id: 9, +}; +const pr = { + number: 42, + state: "open", + head: { sha }, + base: { repo: { full_name: "LiveSplit/LiveSplitOne" } }, +}; + +function githubFixture( + options: { + candidates?: (typeof pr)[]; + current?: typeof pr; + workflow?: string; + expired?: boolean; + } = {}, +) { + const calls: { + method: string; + path: string; + body: Record; + }[] = []; + const fetcher: typeof fetch = (input, init) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + const method = init?.method ?? "GET"; + calls.push({ + method, + path: url.pathname, + body: JSON.parse( + typeof init?.body === "string" ? init.body : "{}", + ) as Record, + }); + let result: unknown; + if (url.pathname.endsWith("/actions/workflows/1")) { + result = { path: options.workflow ?? ".github/workflows/ci.yml" }; + } else if (url.pathname.endsWith(`/commits/${sha}/pulls`)) { + result = options.candidates ?? [pr]; + } else if (url.pathname.endsWith("/pulls/42")) { + result = options.current ?? pr; + } else if (url.pathname.endsWith("/actions/runs/9/artifacts")) { + result = { + total_count: 1, + artifacts: [ + { + id: 7, + name: "livesplit-one-preview", + expired: options.expired ?? false, + }, + ], + }; + } else if (url.pathname.endsWith(`/commits/${sha}/check-runs`)) { + result = { + total_count: 2, + check_runs: [ + { id: 99, app: { slug: "github-actions" } }, + { id: 100, app: { slug: "other-app" } }, + ], + }; + } else if ( + url.pathname.endsWith("/check-runs") || + url.pathname.endsWith("/check-runs/99") + ) { + result = { id: 99 }; + } else { + throw new Error(`Unexpected GitHub request: ${url.pathname}`); + } + const response = new Response(JSON.stringify(result), { + headers: { "content-type": "application/json" }, + }); + // Real fetch responses include their final URL. Octokit's paginator + // uses it to resolve Link headers, even when this is the only page. + Object.defineProperty(response, "url", { value: url.toString() }); + return Promise.resolve(response); + }; + return { github: new Octokit({ request: { fetch: fetcher } }), calls }; +} + +test("discovers fork PRs through signed commit metadata without artifact-supplied identity", async () => { + const { github, calls } = githubFixture(); + assert.deepEqual(await findPreview(github, run, repo), { + number: 42, + sha, + artifact: 7, + }); + assert(calls.some((call) => call.path.endsWith(`/commits/${sha}/pulls`))); +}); + +test("ignores runs from other repositories, failed CI, and non-PR events", async () => { + for (const changes of [ + { event: "push" }, + { conclusion: "failure" }, + { repository: { full_name: "other/repo" } }, + ]) { + const { github, calls } = githubFixture(); + assert.equal( + await findPreview(github, { ...run, ...changes }, repo), + null, + ); + assert.equal(calls.length, 0); + } +}); + +test("rejects a different workflow, expired artifacts, and ambiguous PR identity", async () => { + for (const options of [ + { workflow: ".github/workflows/other.yml" }, + { expired: true }, + { candidates: [pr, { ...pr, number: 43 }] }, + ]) { + assert.equal( + await findPreview(githubFixture(options).github, run, repo), + null, + ); + } +}); + +test("a closed or updated PR cannot publish an old completed build", async () => { + for (const current of [ + { ...pr, state: "closed" }, + { ...pr, head: { sha: "b".repeat(40) } }, + ]) { + const { github, calls } = githubFixture({ current }); + assert.equal(await findPreview(github, run, repo), null); + assert(!calls.some((call) => call.path.endsWith("/artifacts"))); + assert.equal( + isCurrentPullRequest(current, sha, "LiveSplit/LiveSplitOne"), + false, + ); + } +}); + +test("links the stable preview origin to the actual PR commit", async () => { + const { github, calls } = githubFixture(); + const url = "https://pr-42.livesplit-one-previews.pages.dev/"; + await reportPreview(github, repo, 42, sha, url); + assert.equal(calls[0]?.body["head_sha"], sha); + assert.equal(calls[0]?.body["details_url"], url); + for (const bad of [ + "http://pr-42.example.pages.dev", + "https://pr-43.example.pages.dev", + "https://pr-42.example.com", + "https://user:password@pr-42.example.pages.dev", + ]) { + await assert.rejects( + reportPreview(github, repo, 42, sha, bad), + /Unexpected/, + ); + } +}); + +test("cleanup retires only the preview check owned by GitHub Actions", async () => { + const { github, calls } = githubFixture(); + await markPreviewRemoved(github, repo, 42, sha); + const updates = calls.filter((call) => call.method === "PATCH"); + assert.equal(updates.length, 1); + assert.equal(updates[0]?.body["conclusion"], "neutral"); + assert.equal( + updates[0]?.body["details_url"], + "https://github.com/LiveSplit/LiveSplitOne/pull/42", + ); +}); + +test("branch identifiers cannot contain command text or fractional PR numbers", () => { + assert.equal(previewBranch(42), "pr-42"); + for (const value of [0, -1, 1.5, NaN, Infinity]) + assert.throws(() => previewBranch(value)); +}); + +test("accepts a static app but rejects Workers, symlinks, and oversize assets", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "lso-preview-")); + try { + await assert.rejects(validateStaticSite(directory)); + await writeFile( + path.join(directory, "index.html"), + "Preview", + ); + await writeFile(path.join(directory, "core.wasm"), new Uint8Array(8)); + await validateStaticSite(directory); + await writeFile( + path.join(directory, "_worker.js"), + "export default {};", + ); + await assert.rejects(validateStaticSite(directory), /static sites/); + await rm(path.join(directory, "_worker.js")); + await symlink( + path.join(directory, "index.html"), + path.join(directory, "linked.html"), + ); + await assert.rejects(validateStaticSite(directory), /symbolic links/); + await rm(path.join(directory, "linked.html")); + await using file = await open( + path.join(directory, "oversize.wasm"), + "w", + ); + await file.truncate(25 * 1024 * 1024 + 1); + await assert.rejects(validateStaticSite(directory), /25 MiB/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0795530..f0d7f79a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,11 +150,27 @@ jobs: if: matrix.platform == 'web' run: npm run typecheck + - name: Test preview deployment helpers (Web) + if: matrix.platform == 'web' + run: npm run test:previews + - name: Build Frontend (Web) if: matrix.platform == 'web' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run publish + GITHUB_TOKEN: ${{ github.token }} + run: npm run ${{ github.event_name == 'pull_request' && 'publish:preview' || 'publish' }} + + # The existing web job produces the preview. PR code never receives + # Cloudflare credentials; a separate, trusted workflow uploads + # these static files after CI has finished successfully. + - name: Upload pull request preview (Web) + if: matrix.platform == 'web' && github.event_name == 'pull_request' + uses: actions/upload-artifact@v6 + with: + name: livesplit-one-preview + path: dist + if-no-files-found: error + retention-days: 7 - name: Build Frontend (Tauri) if: matrix.platform == 'tauri' diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 00000000..155c006f --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,41 @@ +name: Remove PR preview + +on: + # This includes merges and closes without merging. Only metadata and + # trusted default-branch scripts are used; PR source is never checked out. + pull_request_target: + types: [closed] + +permissions: + contents: read + pull-requests: read + checks: write + +jobs: + cleanup: + if: vars.CLOUDFLARE_ACCOUNT_ID != '' && vars.CLOUDFLARE_PAGES_PROJECT != '' + runs-on: ubuntu-latest + concurrency: + group: pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Delete every deployment for the closed PR + uses: actions/github-script@v8 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + with: + script: | + const { cloudflareClient, removeClosedPreview } = await import(`${process.cwd()}/.github/scripts/preview-cloudflare.mts`); + const number = context.payload.pull_request.number; + const deleted = await removeClosedPreview(github, context.repo, number, cloudflareClient(process.env.CLOUDFLARE_ACCOUNT_ID, process.env.CLOUDFLARE_PAGES_PROJECT, process.env.CLOUDFLARE_API_TOKEN)); + if (deleted === null) { + core.notice('PR reopened before cleanup; preserve its preview.'); + return; + } + await core.summary.addRaw(`Removed ${deleted} preview deployments for PR #${number}.`).write(); diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..7d1ce7a3 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,142 @@ +name: Publish PR preview + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + identify: + # Until the one-time hosting setup is complete, CI still builds and + # uploads downloadable previews without trying to deploy them. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + vars.CLOUDFLARE_ACCOUNT_ID != '' && + vars.CLOUDFLARE_PAGES_PROJECT != '' + runs-on: ubuntu-latest + outputs: + number: ${{ steps.preview.outputs.number }} + sha: ${{ steps.preview.outputs.sha }} + artifact: ${{ steps.preview.outputs.artifact }} + steps: + # workflow_run runs trusted code from the default branch. Never + # check out or execute the PR in a job with deployment credentials. + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: actions/github-script@v8 + id: preview + with: + script: | + const { findPreview } = await import(`${process.cwd()}/.github/scripts/preview-github.mts`); + const preview = await findPreview(github, context.payload.workflow_run, context.repo); + if (preview) { + for (const [key, value] of Object.entries(preview)) core.setOutput(key, value); + } else { + core.notice('No current open PR preview to publish.'); + } + + deploy: + needs: identify + if: needs.identify.outputs.number != '' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: read + checks: write + # Cleanup uses this same group. A close waits for an upload already in + # progress, then removes it; newer runs cannot recreate a closed PR. + concurrency: + group: pr-preview-${{ needs.identify.outputs.number }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: "lts/*" + # Handle a closed PR before downloading its files. An expired or + # unavailable artifact must not prevent a late publisher from + # cleaning up if it replaced the pending close job. + - name: Recheck the PR and preview project + uses: actions/github-script@v8 + id: ready + env: + PR_NUMBER: ${{ needs.identify.outputs.number }} + PR_SHA: ${{ needs.identify.outputs.sha }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + with: + script: | + const { isCurrentPullRequest } = await import(`${process.cwd()}/.github/scripts/preview-github.mts`); + const { cloudflareClient, checkPreviewProject, removeClosedPreview } = await import(`${process.cwd()}/.github/scripts/preview-cloudflare.mts`); + const number = Number(process.env.PR_NUMBER); + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + const client = cloudflareClient(process.env.CLOUDFLARE_ACCOUNT_ID, process.env.CLOUDFLARE_PAGES_PROJECT, process.env.CLOUDFLARE_API_TOKEN); + if (pr.state === 'closed') { + await removeClosedPreview(github, context.repo, number, client); + core.notice('PR closed while waiting; removed its previews.'); + return; + } + if (!isCurrentPullRequest(pr, process.env.PR_SHA, `${context.repo.owner}/${context.repo.repo}`)) { + core.notice('PR closed or changed while the preview was waiting.'); + return; + } + await checkPreviewProject(client, number); + core.setOutput('publish', 'true'); + - uses: actions/download-artifact@v6 + if: steps.ready.outputs.publish == 'true' + with: + artifact-ids: ${{ needs.identify.outputs.artifact }} + merge-multiple: true + path: ${{ runner.temp }}/preview-site + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Validate the static preview + if: steps.ready.outputs.publish == 'true' + uses: actions/github-script@v8 + with: + script: | + const fs = await import('node:fs/promises'); + const { validateStaticSite } = await import(`${process.cwd()}/.github/scripts/preview-github.mts`); + await validateStaticSite(`${process.env.RUNNER_TEMP}/preview-site`); + await fs.mkdir(`${process.env.RUNNER_TEMP}/preview-publisher`, { recursive: true }); + - name: Upload preview + if: steps.ready.outputs.publish == 'true' + id: upload + # Pin the third-party action used with the deployment token. + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: ${{ runner.temp }}/preview-publisher + packageManager: npm + wranglerVersion: "4.149.0" + command: >- + pages deploy ${{ runner.temp }}/preview-site + --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }} + --branch=pr-${{ needs.identify.outputs.number }} + --commit-hash=${{ needs.identify.outputs.sha }} + - name: Add the preview link to the PR checks + if: steps.ready.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ needs.identify.outputs.number }} + PR_SHA: ${{ needs.identify.outputs.sha }} + PREVIEW_URL: ${{ steps.upload.outputs.pages-deployment-alias-url }} + with: + script: | + const { reportPreview } = await import(`${process.cwd()}/.github/scripts/preview-github.mts`); + await reportPreview(github, context.repo, Number(process.env.PR_NUMBER), process.env.PR_SHA, process.env.PREVIEW_URL); + await core.summary.addLink('View LiveSplit One preview', process.env.PREVIEW_URL).write(); diff --git a/eslint.config.ts b/eslint.config.ts index 7d33a7cf..bc5f45a7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -38,6 +38,7 @@ export default tseslint.config({ "buildCore.ts", "eslint.config.ts", "vite.config.ts", + ".github/scripts/*.mts", ], defaultProject: "tsconfig.node.json", }, diff --git a/package-lock.json b/package-lock.json index 7dde80ec..791514b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/plugin-transform-explicit-resource-management": "^8.0.1", + "@octokit/rest": "^22.0.1", "@rolldown/plugin-babel": "^0.2.2", "@tauri-apps/cli": "^2.2.7", "@types/markdown-it": "^14.1.2", @@ -1904,6 +1905,207 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, "node_modules/@oxc-project/types": { "version": "0.153.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", @@ -3452,6 +3654,13 @@ "node": ">=6.0.0" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -3644,6 +3853,20 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5421,6 +5644,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7425,6 +7655,13 @@ "node": ">=8" } }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index 8c4f47b5..9c751d55 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "build:core": "node buildCore.ts", "build:core:release": "node buildCore.ts --release", "build:core:deploy": "node buildCore.ts --max-opt --nightly", - "lint": "eslint src buildCore.ts eslint.config.ts vite.config.ts", + "lint": "eslint src buildCore.ts eslint.config.ts vite.config.ts .github/scripts", "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "publish": "vite build", + "publish:preview": "vite build --mode preview", + "test:previews": "node --test .github/scripts/*.test.mts", "serve": "vite", "tauri:icons": "tauri icon src/assets/icon.png", "tauri:build-html": "vite build --mode tauri", @@ -29,6 +31,7 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/plugin-transform-explicit-resource-management": "^8.0.1", + "@octokit/rest": "^22.0.1", "@rolldown/plugin-babel": "^0.2.2", "@tauri-apps/cli": "^2.2.7", "@types/markdown-it": "^14.1.2", diff --git a/tsconfig.node.json b/tsconfig.node.json index bd128f0b..e5b15838 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,6 +19,8 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, // DOM, WebWorker, and Workbox declarations currently conflict when // TypeScript checks their third-party declaration files together. "skipLibCheck": true, @@ -30,6 +32,7 @@ "buildCore.ts", "eslint.config.ts", "vite.config.ts", + ".github/scripts/*.mts", "src/type-definitions/babel-core-shim.d.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index b7844843..28879fa0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -208,7 +208,7 @@ async function getContributorsForRepo( function preloadPlugin() { let base = "/"; - return { + return { name: "lso-preload-plugin", enforce: "post", configResolved(config) { @@ -274,16 +274,20 @@ function preloadPlugin() { } } }, - }; + } satisfies Plugin; } export default defineConfig(async ({ mode }) => { const isTauri = mode === "tauri"; const isProduction = mode === "production"; + const isPreview = mode === "preview"; await ensureFavicons(); - const isBuildOptimized = isProduction || isTauri; + // Previews use the real optimized frontend and WASM/font preloading, but + // never install the production service worker. A review should load the + // new build immediately rather than retain an earlier PR in its cache. + const isBuildOptimized = isProduction || isTauri || isPreview; let contributorsList: { id: number; name: string }[] = []; const changelog = parseChangelog(); @@ -329,7 +333,9 @@ export default defineConfig(async ({ mode }) => { return { id: user.id, name: user.login }; }); } catch (e) { - if (isBuildOptimized) { + // Contributor credits are useful but must not block a PR preview if + // GitHub is unavailable or rate-limits its read-only build token. + if (isBuildOptimized && !isPreview) { throw e; } console.warn("Failed to load GitHub data, skipping:", e); From adc476ce128897ca3336a02f5717d01b76730aa2 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Mon, 12 Oct 2026 20:17:30 +0000 Subject: [PATCH 2/2] Link Previews in Pull Request Conversations Post a direct preview link in the pull request conversation after a successful upload, alongside the Web preview check. Reviewers can open the running build from the pull request itself without visiting Actions. Update the same bot-owned comment for subsequent builds so the link stays current without posting another notification on every update. Mark that comment as removed when the pull request closes or merges, and avoid posting removal comments for pull requests that never had a preview. Grant pull request write access only to the trusted publishing and cleanup jobs that maintain this comment. Match both the bot identity and the preview marker before editing so other conversation comments stay intact. --- .github/scripts/preview-cloudflare.test.mts | 4 +- .github/scripts/preview-github.mts | 52 +++++++++++++++ .github/scripts/preview-github.test.mts | 72 +++++++++++++++++++++ .github/workflows/preview-cleanup.yml | 2 +- .github/workflows/preview.yml | 4 +- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/.github/scripts/preview-cloudflare.test.mts b/.github/scripts/preview-cloudflare.test.mts index c618c1c3..3b8b13b5 100644 --- a/.github/scripts/preview-cloudflare.test.mts +++ b/.github/scripts/preview-cloudflare.test.mts @@ -23,7 +23,9 @@ test("merged and unmerged closes clean up; a reopened PR keeps its preview", asy ); const data = url.pathname.endsWith("/pulls/42") ? { state, merged, head: { sha: "a".repeat(40) } } - : { total_count: 0, check_runs: [] }; + : url.pathname.endsWith("/issues/42/comments") + ? [] + : { total_count: 0, check_runs: [] }; const response = new Response(JSON.stringify(data), { headers: { "content-type": "application/json" }, }); diff --git a/.github/scripts/preview-github.mts b/.github/scripts/preview-github.mts index a1b27f80..2e832065 100644 --- a/.github/scripts/preview-github.mts +++ b/.github/scripts/preview-github.mts @@ -19,6 +19,7 @@ type PullRequest = { }; export const ARTIFACT_NAME = "livesplit-one-preview"; +const PREVIEW_COMMENT_MARKER = ""; export function previewBranch(number: number): string { if (!Number.isSafeInteger(number) || number <= 0) { @@ -161,6 +162,51 @@ export async function reportPreview( summary: `[View preview](${url}) — automatically removed when this PR closes or merges.`, }, }); + await updatePreviewComment( + github, + repo, + number, + `### LiveSplit One preview\n\n[Open preview](${url})\n\nBuilt from [${sha.slice(0, 7)}](https://github.com/${repo.owner}/${repo.repo}/commit/${sha}). Updated automatically for this PR and removed when it closes or merges.`, + { createIfMissing: true }, + ); +} + +// Keep the link in the PR conversation itself, where reviewers can open it +// without visiting Actions. Update one bot-owned comment instead of posting +// another notification on every build, and leave other comments untouched. +// Cleanup only updates existing comments: PRs that never had a preview should +// not receive a new "preview removed" comment when they close. +async function updatePreviewComment( + github: GitHubClient, + repo: Repository, + number: number, + text: string, + { createIfMissing = false } = {}, +) { + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, + issue_number: number, + per_page: 100, + }); + const comment = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.startsWith(PREVIEW_COMMENT_MARKER), + ); + const body = `${PREVIEW_COMMENT_MARKER}\n\n${text}`; + if (comment) { + await github.rest.issues.updateComment({ + ...repo, + comment_id: comment.id, + body, + }); + } else if (createIfMissing) { + await github.rest.issues.createComment({ + ...repo, + issue_number: number, + body, + }); + } } export async function markPreviewRemoved( @@ -193,4 +239,10 @@ export async function markPreviewRemoved( }); } } + await updatePreviewComment( + github, + repo, + number, + "### LiveSplit One preview removed\n\nThis PR has closed or merged, so its preview deployments were deleted.", + ); } diff --git a/.github/scripts/preview-github.test.mts b/.github/scripts/preview-github.test.mts index 8f60e96f..3d0b3824 100644 --- a/.github/scripts/preview-github.test.mts +++ b/.github/scripts/preview-github.test.mts @@ -37,6 +37,7 @@ function githubFixture( current?: typeof pr; workflow?: string; expired?: boolean; + comments?: { id: number; body: string; user: { login: string } }[]; } = {}, ) { const calls: { @@ -87,6 +88,10 @@ function githubFixture( url.pathname.endsWith("/check-runs/99") ) { result = { id: 99 }; + } else if (url.pathname.endsWith("/issues/42/comments")) { + result = method === "GET" ? (options.comments ?? []) : { id: 101 }; + } else if (url.pathname.endsWith("/issues/comments/101")) { + result = { id: 101 }; } else { throw new Error(`Unexpected GitHub request: ${url.pathname}`); } @@ -173,6 +178,73 @@ test("links the stable preview origin to the actual PR commit", async () => { } }); +test("the first preview posts a ready link directly in the PR conversation", async () => { + const { github, calls } = githubFixture(); + const url = "https://pr-42.livesplit-one-previews.pages.dev/"; + await reportPreview(github, repo, 42, sha, url); + const created = calls.filter( + (call) => + call.method === "POST" && call.path.endsWith("/issues/42/comments"), + ); + assert.equal(created.length, 1); + assert.match(String(created[0]?.body["body"]), /\[Open preview\]/); + assert(String(created[0]?.body["body"]).includes(url)); + assert(String(created[0]?.body["body"]).includes(`/commit/${sha}`)); +}); + +test("new builds update only the existing preview comment owned by the bot", async () => { + const marker = ""; + const { github, calls } = githubFixture({ + comments: [ + { id: 102, body: marker, user: { login: "contributor" } }, + { + id: 103, + body: "An unrelated bot comment.", + user: { login: "github-actions[bot]" }, + }, + { id: 101, body: marker, user: { login: "github-actions[bot]" } }, + ], + }); + const url = "https://pr-42.livesplit-one-previews.pages.dev/"; + await reportPreview(github, repo, 42, sha, url); + const updates = calls.filter((call) => call.method === "PATCH"); + assert.equal(updates.length, 1); + assert(updates[0]?.path.endsWith("/issues/comments/101")); + assert(String(updates[0]?.body["body"]).includes(url)); + assert( + !calls.some( + (call) => + call.method === "POST" && + call.path.endsWith("/issues/42/comments"), + ), + ); +}); + +test("cleanup removes the comment link without posting to PRs that never had a preview", async () => { + for (const comments of [ + [], + [ + { + id: 101, + body: "\n\n[Open preview](https://pr-42.lso.pages.dev)", + user: { login: "github-actions[bot]" }, + }, + ], + ]) { + const { github, calls } = githubFixture({ comments }); + await markPreviewRemoved(github, repo, 42, sha); + const updates = calls.filter((call) => + call.path.endsWith("/issues/comments/101"), + ); + assert.equal(updates.length, comments.length); + if (updates[0]) { + assert.match(String(updates[0].body["body"]), /removed/); + assert(!String(updates[0].body["body"]).includes("pages.dev")); + } + assert(!calls.some((call) => call.method === "POST")); + } +}); + test("cleanup retires only the preview check owned by GitHub Actions", async () => { const { github, calls } = githubFixture(); await markPreviewRemoved(github, repo, 42, sha); diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index 155c006f..a2085ea5 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -8,7 +8,7 @@ on: permissions: contents: read - pull-requests: read + pull-requests: write checks: write jobs: diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 7d1ce7a3..75f9e132 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -50,7 +50,7 @@ jobs: permissions: contents: read actions: read - pull-requests: read + pull-requests: write checks: write # Cleanup uses this same group. A close waits for an upload already in # progress, then removes it; newer runs cannot recreate a closed PR. @@ -128,7 +128,7 @@ jobs: --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }} --branch=pr-${{ needs.identify.outputs.number }} --commit-hash=${{ needs.identify.outputs.sha }} - - name: Add the preview link to the PR checks + - name: Link the preview in the PR conversation and checks if: steps.ready.outputs.publish == 'true' uses: actions/github-script@v8 env: