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..3b8b13b5 --- /dev/null +++ b/.github/scripts/preview-cloudflare.test.mts @@ -0,0 +1,199 @@ +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) } } + : url.pathname.endsWith("/issues/42/comments") + ? [] + : { 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..2e832065 --- /dev/null +++ b/.github/scripts/preview-github.mts @@ -0,0 +1,248 @@ +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"; +const PREVIEW_COMMENT_MARKER = ""; + +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.`, + }, + }); + 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( + 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.", + }, + }); + } + } + 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 new file mode 100644 index 00000000..3d0b3824 --- /dev/null +++ b/.github/scripts/preview-github.test.mts @@ -0,0 +1,297 @@ +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; + comments?: { id: number; body: string; user: { login: string } }[]; + } = {}, +) { + 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 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}`); + } + 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("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); + 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..a2085ea5 --- /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: write + 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..75f9e132 --- /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: 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. + 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: Link the preview in the PR conversation and 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);