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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions web/app/[locale]/(landing)/docs/changelog/[version]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
changelogVersionDescription,
changelogVersionPath,
localizedChangelogPath,
changelogVersionsForPrerender,
type ChangelogVersion,
} from "@/app/lib/changelog";
import { changelogStore } from "@/app/lib/changelog-store";
Expand All @@ -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 }));
}

Expand Down
12 changes: 12 additions & 0 deletions web/app/lib/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand Down
5 changes: 5 additions & 0 deletions web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
4 changes: 2 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions web/tests/changelog-pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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+$/);
});

Expand Down
27 changes: 27 additions & 0 deletions web/tests/sync-changelog.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
141 changes: 141 additions & 0 deletions web/tests/vercel-ignore-build.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
16 changes: 16 additions & 0 deletions web/tools/sync-changelog.ts
Original file line number Diff line number Diff line change
@@ -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}`);
29 changes: 29 additions & 0 deletions web/tools/vercel-ignore-build.sh
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion web/vercel.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"ignoreCommand": "bash tools/vercel-ignore-build.sh",
"git": {
"deploymentEnabled": {
"main": true,
Expand Down Expand Up @@ -35,4 +36,4 @@
"schedule": "49 * * * *"
}
]
}
}