diff --git a/web/.gitignore b/web/.gitignore index 290519f0bb71..dd8f87b0ce88 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -38,6 +38,9 @@ yarn-error.log* # vercel .vercel +# build-time copy of the repository changelog (tools/sync-changelog.ts) +/CHANGELOG.md + # typescript *.tsbuildinfo next-env.d.ts diff --git a/web/app/[locale]/(landing)/docs/changelog/[version]/page.tsx b/web/app/[locale]/(landing)/docs/changelog/[version]/page.tsx index ff12c4fc7883..8a3b94307530 100644 --- a/web/app/[locale]/(landing)/docs/changelog/[version]/page.tsx +++ b/web/app/[locale]/(landing)/docs/changelog/[version]/page.tsx @@ -19,6 +19,7 @@ import { changelogVersionDescription, changelogVersionPath, localizedChangelogPath, + changelogVersionsForPrerender, type ChangelogVersion, } from "@/app/lib/changelog"; import { changelogStore } from "@/app/lib/changelog-store"; @@ -29,8 +30,7 @@ type PageParams = { locale: string; version: string }; export function generateStaticParams() { - return changelogStore - .versions() + return changelogVersionsForPrerender(changelogStore.versions()) .map((release) => ({ version: release.version })); } diff --git a/web/app/lib/changelog.ts b/web/app/lib/changelog.ts index 9bef514a1a69..e0d95356afeb 100644 --- a/web/app/lib/changelog.ts +++ b/web/app/lib/changelog.ts @@ -12,6 +12,11 @@ export interface ChangelogVersion { export const changelogPath = "/docs/changelog"; +// Keep the newest releases ready at deploy time. Cache Components resolves +// omitted versions at request time, so historical URLs stay available without +// making every locale/version combination part of every deployment. +export const changelogStaticVersionCount = 12; + export interface ChangelogVersionEntry { release: ChangelogVersion; index: number; @@ -26,6 +31,13 @@ export interface ChangelogSource { read(): string; } +/** Returns the releases that should be prerendered during a deployment. */ +export function changelogVersionsForPrerender( + versions: readonly ChangelogVersion[], +): readonly ChangelogVersion[] { + return versions.slice(0, changelogStaticVersionCount); +} + interface ChangelogSnapshot { fingerprint: string; versions: readonly ChangelogVersion[]; diff --git a/web/next.config.ts b/web/next.config.ts index d867f3574752..3a5d63075c5b 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -186,6 +186,11 @@ const nextConfig: NextConfig = { "./public/logo.png", ], "**/browser-opengraph-image": ["./public/logo.png"], + // Changelog versions outside generateStaticParams render at request time + // and read the copy that tools/sync-changelog.ts writes before the build. + "**/docs/changelog": ["./CHANGELOG.md"], + "**/docs/changelog/**": ["./CHANGELOG.md"], + "**/sitemap.xml": ["./CHANGELOG.md"], }, images: { // AVIF first: for the detailed hero screenshot (crisp terminal text + diff --git a/web/package.json b/web/package.json index 534941fba699..3daaa763278b 100644 --- a/web/package.json +++ b/web/package.json @@ -5,8 +5,8 @@ "private": true, "scripts": { "dev": "exec bash scripts/dev-local.sh", - "build": "bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && next build", - "vercel-build": "bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && VERCEL_PREVIEW_COMMENTS_ENABLED=0 next build", + "build": "bun tools/sync-changelog.ts && bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && next build", + "vercel-build": "bun tools/sync-changelog.ts && bun tools/generate-managed-iroh-relay-catalog.ts --check && bun tools/build-docs-search.mjs && VERCEL_PREVIEW_COMMENTS_ENABLED=0 next build", "search:index": "bun tools/build-docs-search.mjs", "subrouter:migrate-legacy": "bun scripts/subrouter/migrate-legacy-tenants.ts", "coderouter:migrate-encrypted-credentials": "bun scripts/coderouter/migrate-encrypted-credentials.ts", diff --git a/web/tests/changelog-pages.test.tsx b/web/tests/changelog-pages.test.tsx index 1b9de02aa56e..2313f2b8ac0e 100644 --- a/web/tests/changelog-pages.test.tsx +++ b/web/tests/changelog-pages.test.tsx @@ -4,8 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { createTranslator } from "use-intl/core"; import { changelogPath, + changelogStaticVersionCount, changelogVersionDescription, changelogVersionPath, + changelogVersionsForPrerender, localizedChangelogPath, ChangelogStore, parseChangelog, @@ -102,15 +104,23 @@ Release intro. expect(store.versions()[0]?.version).toBe("2.0.0"); }); - test("pre-renders a route for every version in the source changelog", () => { + test("pre-renders recent versions and keeps historical routes available", () => { const versions = changelogStore .versions() .map((release) => release.version); + const expectedStaticVersions = versions.slice(0, changelogStaticVersionCount); expect(generateStaticParams()).toEqual( - versions.map((version) => ({ version })), + expectedStaticVersions.map((version) => ({ version })), ); expect(versions.length).toBeGreaterThan(80); + expect(expectedStaticVersions.length).toBe(changelogStaticVersionCount); + expect(changelogVersionsForPrerender(changelogStore.versions())).toHaveLength( + changelogStaticVersionCount, + ); + expect(generateStaticParams()).not.toContainEqual({ + version: versions.at(-1), + }); expect(versions[0]).toMatch(/^\d+\.\d+\.\d+$/); }); diff --git a/web/tests/sync-changelog.test.ts b/web/tests/sync-changelog.test.ts new file mode 100644 index 000000000000..08ceebbad72c --- /dev/null +++ b/web/tests/sync-changelog.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const syncScript = fileURLToPath( + new URL("../tools/sync-changelog.ts", import.meta.url), +); + +test("copies the repository changelog into the web project", () => { + const directory = mkdtempSync(join(tmpdir(), "sync-changelog-")); + try { + const source = join(directory, "CHANGELOG.md"); + const destination = join(directory, "web", "CHANGELOG.md"); + writeFileSync(source, "## 1.2.3\n\n- Release note\n"); + + execFileSync("bun", [syncScript, source, destination], { + encoding: "utf8", + }); + + expect(readFileSync(destination, "utf8")).toBe(readFileSync(source, "utf8")); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/web/tests/vercel-ignore-build.test.ts b/web/tests/vercel-ignore-build.test.ts new file mode 100644 index 000000000000..ba88dfff7146 --- /dev/null +++ b/web/tests/vercel-ignore-build.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ignoreBuildScript = fileURLToPath( + new URL("../tools/vercel-ignore-build.sh", import.meta.url), +); +let repository: string; + +function git(...args: string[]): string { + return execFileSync("git", args, { + cwd: repository, + encoding: "utf8", + }).trim(); +} + +function commit(message: string): string { + git("add", "."); + git( + "-c", + "user.name=Vercel test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + message, + ); + return git("rev-parse", "HEAD"); +} + +function ignoreBuild( + previous: string | undefined, + current: string, +): number | null { + const result = spawnSync("bash", [ignoreBuildScript], { + cwd: join(repository, "web"), + encoding: "utf8", + env: { + ...process.env, + VERCEL_GIT_PREVIOUS_SHA: previous ?? "", + VERCEL_GIT_COMMIT_SHA: current, + }, + }); + return result.status; +} + +beforeEach(() => { + repository = mkdtempSync(join(tmpdir(), "cmux-vercel-ignore-")); + mkdirSync(join(repository, "web", "tools"), { recursive: true }); + mkdirSync(join(repository, "config", "iroh"), { recursive: true }); + mkdirSync(join(repository, "workers", "presence", "src", "generated"), { + recursive: true, + }); + mkdirSync(join(repository, "Sources"), { recursive: true }); + writeFileSync(join(repository, "web", "page.tsx"), "export default null;\n"); + writeFileSync(join(repository, ".vercelignore"), "node_modules/\n"); + writeFileSync(join(repository, "CHANGELOG.md"), "## [1.0.0] - 2026-01-01\n"); + writeFileSync( + join(repository, "config", "iroh", "managed-relay-catalog.json"), + "{}\n", + ); + writeFileSync( + join(repository, "workers", "presence", "src", "generated", "managedRelayCatalog.ts"), + "export {};\n", + ); + writeFileSync(join(repository, "Sources", "App.swift"), "let app = true\n"); + git("init", "-q"); + git("config", "user.name", "Vercel test"); + git("config", "user.email", "test@example.com"); +}); + +afterEach(() => { + if (repository && existsSync(repository)) { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("skips commits that do not change web build inputs", () => { + const base = commit("base"); + + expect(ignoreBuild(undefined, base)).toBe(1); + expect(ignoreBuild(base, base)).toBe(0); + + writeFileSync(join(repository, "Sources", "App.swift"), "let app = false\n"); + const nativeChange = commit("native change"); + expect(ignoreBuild(base, nativeChange)).toBe(0); +}); + +test("builds when a web or shared build input changes", () => { + const base = commit("base"); + + writeFileSync( + join(repository, "web", "page.tsx"), + "export default function Page() {}\n", + ); + const webChange = commit("web change"); + expect(ignoreBuild(base, webChange)).toBe(1); + + rmSync(join(repository, "web", "page.tsx")); + const webDeletion = commit("web deletion"); + expect(ignoreBuild(webChange, webDeletion)).toBe(1); + + writeFileSync(join(repository, "CHANGELOG.md"), "## [1.0.1] - 2026-01-02\n"); + const changelogChange = commit("changelog change"); + expect(ignoreBuild(webDeletion, changelogChange)).toBe(1); + + writeFileSync(join(repository, ".vercelignore"), "node_modules/\nbuild/\n"); + const vercelIgnoreChange = commit("Vercel ignore change"); + expect(ignoreBuild(changelogChange, vercelIgnoreChange)).toBe(1); + + writeFileSync( + join(repository, "config", "iroh", "managed-relay-catalog.json"), + '{"sequence":2}\n', + ); + const configChange = commit("relay config change"); + expect(ignoreBuild(vercelIgnoreChange, configChange)).toBe(1); + + writeFileSync( + join( + repository, + "workers", + "presence", + "src", + "generated", + "managedRelayCatalog.ts", + ), + "export const sequence = 2;\n", + ); + const generatedChange = commit("generated relay change"); + expect(ignoreBuild(configChange, generatedChange)).toBe(1); + expect(ignoreBuild("missing-sha", generatedChange)).toBe(1); +}); diff --git a/web/tools/sync-changelog.ts b/web/tools/sync-changelog.ts new file mode 100644 index 000000000000..cc744781fe04 --- /dev/null +++ b/web/tools/sync-changelog.ts @@ -0,0 +1,16 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Vercel functions only contain files that Next traces inside the project +// root. The changelog routes read CHANGELOG.md at request time for versions +// that are not prerendered, so copy the repository file into web/ before +// `next build` and let next.config.ts trace the copy. +const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const [sourceArg, destinationArg] = process.argv.slice(2); +const sourcePath = resolve(webRoot, sourceArg ?? "../CHANGELOG.md"); +const destinationPath = resolve(webRoot, destinationArg ?? "CHANGELOG.md"); + +await mkdir(dirname(destinationPath), { recursive: true }); +await copyFile(sourcePath, destinationPath); +console.log(`Synced ${sourcePath} -> ${destinationPath}`); diff --git a/web/tools/vercel-ignore-build.sh b/web/tools/vercel-ignore-build.sh new file mode 100755 index 000000000000..637dd868cd10 --- /dev/null +++ b/web/tools/vercel-ignore-build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Vercel runs this from the project root, which is web/. Return 0 to skip the +# build and 1 to continue it. If Git history is incomplete, build defensively. + +previous_sha="${VERCEL_GIT_PREVIOUS_SHA:-}" +current_sha="${VERCEL_GIT_COMMIT_SHA:-HEAD}" + +if [[ -z "$previous_sha" ]] || ! git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then + echo "No usable previous Vercel deployment SHA; running the build." + exit 1 +fi + +# The web project reads these files outside its root during build generation. +build_inputs=( + "." + "../.vercelignore" + "../CHANGELOG.md" + "../config/iroh/managed-relay-catalog.json" + "../workers/presence/src/generated/managedRelayCatalog.ts" +) + +if git diff --quiet "$previous_sha" "$current_sha" -- "${build_inputs[@]}"; then + echo "No web build inputs changed; skipping the Vercel build." + exit 0 +fi + +echo "Web build inputs changed; running the Vercel build." +exit 1 diff --git a/web/vercel.json b/web/vercel.json index ac429ab9db56..6395f8fa0f49 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -1,5 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "ignoreCommand": "bash tools/vercel-ignore-build.sh", "git": { "deploymentEnabled": { "main": true, @@ -35,4 +36,4 @@ "schedule": "49 * * * *" } ] -} \ No newline at end of file +}