mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
5188f5b003
Serves `content/docs/v5` and `content/worlds/v5` unprefixed at /docs, /worlds and /cookbook, moves v4 under /v4, and puts What's new first in the v5 sidebar. The versioned source config drives both route trees (the root tree always renders `versionedSources.current`), so this is a `routePrefix` move plus source re-binding rather than a restructure. The only file moves are `app/[lang]/v5/**` → `app/[lang]/v4/**`. - Version switcher: `v5 (Latest)` / `v4 (Maintenance)`, `current: 'v5'`. - `pre-release-banner.tsx` becomes `maintenance-banner.tsx`. v4 pages carry an amber notice whose "Go to Workflow 5 (Latest)" link deep-links to the same page on the current version, falling back to the nearest section index for v4-only pages, and keep `robots: noindex, follow`. - Redirects: `/v5/*` to the unprefixed equivalent (bare `/v5` needs its own rule, since `:path*` expands to an empty Location). The world-docs and api-reference restructure rules are mirrored onto `/v4/docs/*`, and every page existing in only one tree gets a version-switcher fallback. - Both worlds route trees pass an explicit version into the shared page components, whose semantics flipped with the switch, so the smoke checks now assert the pairing: a " · v4" title marker and noindex on the maintenance routes, neither on the canonical ones, and a community world serving directly rather than self-redirecting. - The link lint's two URL spaces swap with the prefixes. Redirect destinations resolve against the real HTTP space, since redirects are matched before render-time href rewriting. - The two `/v4/...` links this makes expressible are restored: a v5 page cannot link to a v4 page while v4 is the unprefixed version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { getTrustedSourcesHeaders } from '../../scripts/trusted-sources-headers.mjs';
|
|
|
|
/**
|
|
* Docs smoke checks.
|
|
*
|
|
* This script is intentionally small and dependency-free so it can run in CI.
|
|
* It validates critical public endpoints (OG images and sitemap) and can be
|
|
* extended with more lightweight checks over time.
|
|
*
|
|
* When DEPLOYMENT_URL or OG_BASE_URL is set, it targets a remote deployment.
|
|
* Otherwise it starts the local docs server and tests against localhost.
|
|
*/
|
|
|
|
const PORT = process.env.OG_TEST_PORT || '3100';
|
|
const HOST = '127.0.0.1';
|
|
const rawBaseUrl = process.env.DEPLOYMENT_URL || process.env.OG_BASE_URL || '';
|
|
const BASE_URL = rawBaseUrl
|
|
? rawBaseUrl.startsWith('http')
|
|
? rawBaseUrl
|
|
: `https://${rawBaseUrl}`
|
|
: `http://${HOST}:${PORT}`;
|
|
const USE_REMOTE = Boolean(rawBaseUrl);
|
|
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const assertNoProtection = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
redirect: 'manual',
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
const location = res.headers.get('location') || '';
|
|
if (
|
|
res.status === 307 &&
|
|
(location.includes('vercel.com/login') ||
|
|
location.includes('/_vercel/login'))
|
|
) {
|
|
throw new Error(
|
|
`${path} redirected to Vercel login; check deployment protection/bypass`
|
|
);
|
|
}
|
|
};
|
|
|
|
const waitForServer = async (url, timeoutMs = 30_000) => {
|
|
const startedAt = Date.now();
|
|
let lastStatus = null;
|
|
let lastBody = null;
|
|
// x-vercel-id identifies the Vercel edge node that served the response.
|
|
// While the proxy-side trusted-sources changes are rolling out gradually,
|
|
// a failing request may be hitting an edge node that hasn't received the
|
|
// fix yet — surfacing the id in the timeout error makes that visible.
|
|
let lastVercelId = null;
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (res.ok) return;
|
|
lastStatus = res.status;
|
|
lastVercelId = res.headers.get('x-vercel-id');
|
|
// Capture the first ~500 chars of the body to help diagnose
|
|
// protection/auth failures (e.g. SSO login redirects).
|
|
try {
|
|
lastBody = (await res.text()).slice(0, 500);
|
|
} catch {
|
|
lastBody = '<unable to read body>';
|
|
}
|
|
} catch (err) {
|
|
lastStatus = `network error: ${(err && err.message) || err}`;
|
|
}
|
|
await wait(500);
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for server at ${url} (last status: ${lastStatus}; x-vercel-id: ${lastVercelId}; body: ${lastBody})`
|
|
);
|
|
};
|
|
|
|
const assertPngResponse = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('image/png')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
|
|
if (buf[i] !== PNG_SIGNATURE[i]) {
|
|
throw new Error(`${path} did not start with PNG signature bytes`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const assertHtmlMeta = async (path, expectedOgImagePath) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('text/html')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const html = await res.text();
|
|
const ogImage = html.match(
|
|
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogImage) {
|
|
throw new Error(`${path} missing og:image meta tag`);
|
|
}
|
|
if (expectedOgImagePath) {
|
|
const normalized = ogImage.startsWith('http')
|
|
? new URL(ogImage).pathname
|
|
: ogImage;
|
|
if (normalized !== expectedOgImagePath) {
|
|
throw new Error(
|
|
`${path} og:image was ${ogImage}, expected ${expectedOgImagePath}`
|
|
);
|
|
}
|
|
}
|
|
const twitterImage = html.match(
|
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!twitterImage) {
|
|
throw new Error(`${path} missing twitter:image meta tag`);
|
|
}
|
|
if (expectedOgImagePath) {
|
|
const normalized = twitterImage.startsWith('http')
|
|
? new URL(twitterImage).pathname
|
|
: twitterImage;
|
|
if (normalized !== expectedOgImagePath) {
|
|
throw new Error(
|
|
`${path} twitter:image was ${twitterImage}, expected ${expectedOgImagePath}`
|
|
);
|
|
}
|
|
}
|
|
const ogTitle = html.match(
|
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogTitle) {
|
|
throw new Error(`${path} missing og:title meta tag`);
|
|
}
|
|
const ogDescription = html.match(
|
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogDescription) {
|
|
throw new Error(`${path} missing og:description meta tag`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The unprefixed world routes must serve the current version (no " · v4"
|
|
* title marker, indexable) and the /v4 routes the maintenance version
|
|
* (" · v4" marker, noindex). Guards against the version passed by the route
|
|
* files drifting out of sync with the version semantics in
|
|
* components/worlds/world-detail-page.tsx.
|
|
*/
|
|
const assertWorldVersionMarkers = async (path, { maintenance }) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const html = await res.text();
|
|
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1] ?? '';
|
|
const hasV4Marker = title.includes('· v4');
|
|
if (maintenance && !hasV4Marker) {
|
|
throw new Error(`${path} title was "${title}", expected a " · v4" marker`);
|
|
}
|
|
if (!maintenance && hasV4Marker) {
|
|
throw new Error(
|
|
`${path} title was "${title}", expected the current version (no " · v4" marker)`
|
|
);
|
|
}
|
|
const hasNoindex =
|
|
/<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*noindex/i.test(html);
|
|
if (maintenance && !hasNoindex) {
|
|
throw new Error(`${path} is missing the robots noindex meta tag`);
|
|
}
|
|
if (!maintenance && hasNoindex) {
|
|
throw new Error(`${path} is unexpectedly noindexed`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Community worlds have no versioned content; their canonical page must serve
|
|
* directly. A version mismatch in the world routes turns them into
|
|
* self-redirect loops, so assert a plain 200 with no redirect.
|
|
*/
|
|
const assertServesDirectly = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
redirect: 'manual',
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (res.status !== 200) {
|
|
const location = res.headers.get('location');
|
|
throw new Error(
|
|
`${path} returned ${res.status}${location ? ` -> ${location}` : ''}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const checks = [
|
|
{
|
|
name: 'Deployment protection',
|
|
run: () => assertNoProtection('/og'),
|
|
},
|
|
{
|
|
name: 'OG default image',
|
|
run: () => assertPngResponse('/og'),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs root',
|
|
run: () => assertHtmlMeta('/docs', '/og/getting-started/image.png'),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs idempotency',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/docs/foundations/idempotency',
|
|
'/og/foundations/idempotency/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - cookbook sequential & parallel',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/cookbook/common-patterns/sequential-and-parallel',
|
|
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs get-writable',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/docs/api-reference/workflow/get-writable',
|
|
'/og/api-reference/workflow/get-writable/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - worlds index',
|
|
run: () => assertHtmlMeta('/worlds', '/og/worlds'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world local',
|
|
run: () => assertHtmlMeta('/worlds/local', '/og/worlds/local'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world postgres',
|
|
run: () => assertHtmlMeta('/worlds/postgres', '/og/worlds/postgres'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world vercel',
|
|
run: () => assertHtmlMeta('/worlds/vercel', '/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'HTML meta - worlds building-a-world',
|
|
run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'),
|
|
},
|
|
{
|
|
name: 'HTML meta - worlds upgrading-to-v5',
|
|
run: () => assertHtmlMeta('/worlds/upgrading-to-v5', '/og/worlds'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world vercel (v4)',
|
|
run: () => assertHtmlMeta('/v4/worlds/vercel', '/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'World version markers - vercel (current)',
|
|
run: () =>
|
|
assertWorldVersionMarkers('/worlds/vercel', { maintenance: false }),
|
|
},
|
|
{
|
|
name: 'World version markers - vercel (v4)',
|
|
run: () =>
|
|
assertWorldVersionMarkers('/v4/worlds/vercel', { maintenance: true }),
|
|
},
|
|
{
|
|
name: 'Community world serves directly - turso',
|
|
run: () => assertServesDirectly('/worlds/turso'),
|
|
},
|
|
{
|
|
name: 'OG docs page image',
|
|
run: () => assertPngResponse('/og/foundations/idempotency/image.png'),
|
|
},
|
|
{
|
|
name: 'OG docs root image',
|
|
run: () => assertPngResponse('/og/getting-started/image.png'),
|
|
},
|
|
{
|
|
name: 'OG cookbook common-patterns image',
|
|
run: () =>
|
|
assertPngResponse(
|
|
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'OG docs reference image',
|
|
run: () =>
|
|
assertPngResponse('/og/api-reference/workflow/get-writable/image.png'),
|
|
},
|
|
{
|
|
name: 'OG worlds index image',
|
|
run: () => assertPngResponse('/og/worlds'),
|
|
},
|
|
{
|
|
name: 'OG world image (local)',
|
|
run: () => assertPngResponse('/og/worlds/local'),
|
|
},
|
|
{
|
|
name: 'OG world image (postgres)',
|
|
run: () => assertPngResponse('/og/worlds/postgres'),
|
|
},
|
|
{
|
|
name: 'OG world image (vercel)',
|
|
run: () => assertPngResponse('/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'Sitemap',
|
|
run: () => assertXmlResponse('/sitemap.xml'),
|
|
},
|
|
];
|
|
|
|
const assertXmlResponse = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('xml') && !contentType.includes('text/plain')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const text = await res.text();
|
|
if (!text.includes('<?xml')) {
|
|
throw new Error(`${path} did not contain xml declaration`);
|
|
}
|
|
};
|
|
|
|
const run = async () => {
|
|
let child = null;
|
|
let stopServer = async () => {};
|
|
let cleanup = async () => {};
|
|
|
|
if (!USE_REMOTE) {
|
|
child = spawn('pnpm', ['-C', 'docs', 'start'], {
|
|
env: {
|
|
...process.env,
|
|
PORT,
|
|
HOSTNAME: HOST,
|
|
},
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
let shuttingDown = false;
|
|
stopServer = async () => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
child.kill('SIGTERM');
|
|
await Promise.race([
|
|
new Promise((resolve) => child.once('exit', resolve)),
|
|
wait(5_000),
|
|
]);
|
|
if (!child.killed) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
};
|
|
|
|
cleanup = async () => {
|
|
try {
|
|
await stopServer();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
};
|
|
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
process.on('exit', cleanup);
|
|
}
|
|
|
|
try {
|
|
await waitForServer(`${BASE_URL}/og`);
|
|
for (const check of checks) {
|
|
console.log(`Running docs smoke check: ${check.name}`);
|
|
await check.run();
|
|
}
|
|
await stopServer();
|
|
} catch (error) {
|
|
await stopServer();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|