From 51dbed10777ff9690c6df0f34b8e2ca364007211 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 04:17:59 +0000 Subject: [PATCH] site: take sitemap dates from git, because mtime is a lie in CI The lastmod fix in #5 worked locally and did nothing in production. Deployed, all eighteen URLs still carried one identical date -- 2026-08-30T04:07:33, the build's own checkout time. mtime was the wrong source. A git clone stamps every file it writes with the moment it wrote it, so on a CI checkout every file in the tree shares one mtime and the per-page dates collapse straight back into the single build timestamp the change existed to remove. A working tree has real edit times, which is exactly why local verification passed and proved nothing. Reproduced by cloning the repository and comparing the two sources against the same thirteen files: distinct mtimes: 1 distinct git dates: 6 Six rather than thirteen because several docs were committed together, which is the honest answer: those pages did change at the same moment. So the date now comes from `git log -1 --format=%cI -- `, which is a record of when the content changed rather than when the machine last touched the file. mtime stays as a fallback for a build with no git history, where it is no worse than the build clock it replaces, and the build clock remains the last resort. Verified against a build rather than a working tree: nine distinct dates across the eighteen URLs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y5jnkZKX4AdPgBMzMosxE7 --- apps/web/app/sitemap.ts | 64 +++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index 3050860..05680dc 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,37 +1,59 @@ +import { execFile } from 'node:child_process' import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { promisify } from 'node:util' import type { MetadataRoute } from 'next' import { listDocs } from '@/lib/docs' import { SITE } from '@/lib/site' +const run = promisify(execFile) + /** * Every URL used to carry `new Date()`, so all eighteen shared one build - * timestamp. A lastmod that changes on every deploy and is identical across the - * site says nothing about what actually changed, and crawlers discount it — - * which is worse than sending none, because it costs a field and buys nothing. + * timestamp. A lastmod that is identical site-wide and changes on every deploy + * says nothing about what actually changed, so crawlers discount it — which is + * worse than sending none, because it costs a field and buys nothing. * - * The real date is on disk. Doc pages are rendered from the repository's own - * `docs/*.md`, so that file's mtime *is* when the page last changed; the rest - * are their own source files. Falls back to the build time only when a stat - * fails, which should not happen but must not break the sitemap if it does. + * The date has to come from git, not from the filesystem. An earlier version of + * this read mtimes, which is correct on a working tree and useless in CI: a + * clone stamps every file with the checkout time, so in production all eighteen + * collapsed back to a single date and the fix silently did nothing. mtime is + * kept only as a fallback for a build with no git history, where it is no worse + * than the build clock it replaces. */ const ROOT = join(process.cwd(), '..', '..') const buildTime = new Date() -async function modified(...relative: string[]): Promise { - const times = await Promise.all( - relative.map(async (path) => { - try { - return (await stat(join(ROOT, path))).mtime - } catch { - return null - } - }), +/** When git last changed this path, or null if git cannot say. */ +async function committed(path: string): Promise { + try { + const { stdout } = await run('git', ['log', '-1', '--format=%cI', '--', path], { cwd: ROOT }) + const iso = stdout.trim() + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? null : date + } catch { + // No git, no history, or a shallow clone that does not reach this commit. + return null + } +} + +async function touched(path: string): Promise { + try { + return (await stat(join(ROOT, path))).mtime + } catch { + return null + } +} + +/** The newest real date across the sources a page is built from. */ +async function modified(...paths: string[]): Promise { + const dates = await Promise.all( + paths.map(async (path) => (await committed(path)) ?? (await touched(path))), ) - const known = times.filter((time): time is Date => time !== null) + const known = dates.filter((date): date is Date => date !== null) if (known.length === 0) return buildTime - // The newest of the sources a page is built from. - return new Date(Math.max(...known.map((time) => time.getTime()))) + return new Date(Math.max(...known.map((date) => date.getTime()))) } export default async function sitemap(): Promise { @@ -40,9 +62,9 @@ export default async function sitemap(): Promise { const [home, download, docsIndex, security, privacy] = await Promise.all([ modified(`${web}/app/page.tsx`, `${web}/app/layout.tsx`), - modified(`${web}/app/download/page.tsx`), + modified(`${web}/app/download/page.tsx`, `${web}/lib/releases.ts`), modified(`${web}/app/docs/page.tsx`), - modified(`${web}/app/security/page.tsx`), + modified(`${web}/app/security/page.tsx`, 'docs/security.md'), modified(`${web}/app/privacy/page.tsx`), ])