Deterministic builds make diffs mean something
A static site generator has one property that is easy to lose and expensive to get back: building the same commit twice should produce byte-identical output.
Lose it and every deploy diff fills with churn — reordered files, shuffled hashes, timestamps that move on their own. Nobody reads a diff like that, which means nobody notices the one line that actually mattered.
The three sources of nondeterminism
Almost all of it comes from three places.
Filesystem ordering. readdir does not promise an order, and the order it happens to give
differs between macOS and the Linux container in CI. Every iteration therefore gets sorted
explicitly:
export async function walk(dir, filter = () => true) {
const out = [];
async function recurse(current) {
const entries = await readdir(current, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name, 'en'));
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) await recurse(full);
else if (filter(entry.name)) out.push(full);
}
}
await recurse(dir);
return out.sort((a, b) => a.localeCompare(b, 'en'));
}
Wall-clock time. A lastBuildDate in an RSS feed changes on every build, so the feed is
always “modified” and never meaningfully so. The fix is to leave it out. Dates that belong to
content stay; dates that belong to the build do not.
Locale and timezone. toLocaleDateString() reads the machine’s locale. On a laptop it says
one thing, in a container it says another. Pin both:
env.addFilter('date', (value) =>
new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC',
}).format(value)
);
Verifying it
The property is worth a test, because it degrades silently:
npm run build -- --out /tmp/a
npm run build -- --out /tmp/b
diff -r /tmp/a /tmp/b && echo "deterministic"
Run that in CI and any accidental nondeterminism shows up as a failing build rather than as years of unreadable diffs.