Working log for this blog. Previous: Documentation That Outlived Its Code.

Last episode I audited every Markdown file in this repo against the code and found that all seven described a site that no longer existed. The obvious follow-up was to point the same suspicion at the build system: not "does it run?" — it runs fine, 36 posts in about four seconds — but "does what it claims to produce actually reach the server?"

The first thing I checked was the RSS feed.

curl -sL -o /dev/null -w "%{http_code}\n" https://jeffreyjose07.is-a.dev/blog/feed.xml 404

Every page on this blog has advertised that URL for months.


Nobody wrote a bug

The interesting part is that no line of code here is wrong. Four decisions, each defensible in isolation, composed into a file that only ever existed inside a GitHub Actions runner.

Decision one. The feed generator only runs under CI:

if (process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true') {     generateRSSfeed(posts); } else {     console.log('ℹ️ Skipping RSS feed generation (not running in CI).'); }

Reasonable-sounding: the feed embeds absolute production URLs, so why generate it on a laptop?

Decision two. .gitignore excludes it, filed under a stray # Vercel heading:

# Vercel .vercel public/blog/feed.xml

Also reasonable: it's a build artifact, and build artifacts don't belong in git.

Decision three. CI commits its generated output by directory:

git add public/blog/ public/assets/thumbnails/

And git add on a directory silently skips ignored files. No error. No warning. The feed is generated on the runner, staged by nobody, and evaporates when the job ends.

Decision four. The blog build is conditional, to avoid rebuilding 36 posts when only React code changed:

- name: Build blog   if: steps.blog-changes.outputs.blog_changed == 'true'

Put those together and the feed exists in exactly one situation: a deploy whose commit touched blog/. Every other deploy publishes a dist/ with no feed in it. My most recent commit before this audit was fix(RecentWriting): show four postssrc/ only. So the live site lost its feed, and the three places advertising it kept advertising it:

<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/blog/feed.xml" />

Plus a visible footer link, plus a sitemap.xml entry submitting it to search engines with changefreq: daily — asking Google to crawl a 404 every day.

The control experiment was sitting right next to it. sitemap.xml is generated by the same script into the same directory and returns 200. The only difference: it isn't gitignored.

The generalisable rule: a file that is both conditionally generated and excluded from version control has no durable home. It exists only in the window between generation and deploy, and any path that skips generation ships a site without it.

The sitemap was lying too

Second thing I checked: rebuild with zero content changes and see what git status says.

 M public/blog/search.json  M public/blog/sitemap.xml

Sixty lines of diff from a no-op build:

     <loc>https://jeffreyjose07.is-a.dev/</loc> -    <lastmod>2026-08-01</lastmod> +    <lastmod>2026-08-05</lastmod>

The static pages were stamped with new Date() — the build date, not the content date. So every build told crawlers the homepage had changed when it hadn't.

Google's own guidance is that it uses lastmod only when the value is "consistently and verifiably accurate." Stamping today's date on everything is precisely how you teach a crawler that your lastmod is noise, at which point it stops reading it — and you've lost a real signal for the one time a page genuinely changes.

The same file also carried <priority> and <changefreq> on every URL. Google ignores both. They were so widely abused — everyone setting priority=1.0 on everything — that the fields became meaningless. Two-thirds of that file was decoration, and the remaining third was actively counterproductive.

Hugo solved this years ago with enableGitInfo: lastmod comes from the last git commit that touched the file. It's honest by construction, because the thing you're claiming (this content changed on this date) is exactly what git recorded. The one gotcha is that CI needs a deep clone, or every file reports the clone commit. This repo already checks out with fetch-depth: 0.

const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', relPath], {     cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], });

Local builds and CI builds disagreed

The search.json line in that diff was a different bug wearing the same clothes.

Three generators each sorted the shared posts array in place:

const sortedPosts = posts.sort((a, b) => b.episodeNumber - a.episodeNumber);   // index const sortedPosts = posts.sort((a, b) => new Date(b.date) - new Date(a.date)); // feed postsData.sort((a, b) => parseInt(b.episode) - parseInt(a.episode));           // json

Array.prototype.sort mutates. So each generator inherited whatever order the previous one left behind — and because the feed generator only ran in CI, the archive and sitemap came out in one order on my laptop and a different order on the runner.

That's the worst kind of build bug. It doesn't break anything visible; it just means git status shows phantom diffs you can't reproduce, until you stop trusting git status.

The fix is four characters: [...posts].sort(...).

What the mature generators do

Since I was already rewriting this, I read how projects with more users than me handle the same problems.

Astro's content collections validate frontmatter against a Zod schema at build time and fail loudly rather than deploying broken content. My build validated nothing. A missing description would have sailed through and rendered undefined into a meta tag.

I don't want a Zod dependency in a build script this small, but the idea is free:

for (const field of ['title', 'date', 'description']) {     if (!frontmatter[field] || String(frontmatter[field]).trim() === '') {         errors.push(`${where}: missing required frontmatter "${field}"`);     } }

The check that actually earns its keep is one no general-purpose generator could know about. Episode numbers here come from array position:

const posts = included.map(({ file, frontmatter, index }) => ({     episodeNumber: index,   // ← derived from sort order, not the filename     ... }));

The 012- prefix in the filename is decorative. Delete one post and every later episode silently renumbers — in prose, in URLs, in the "previous episode" links that every post opens with. The invariant held across all 36 posts, but nothing enforced it. Now something does:

blog/posts/013-migrating-from-render-to-neon-postgresql.md: filename prefix 013 but sort position 012 — renumbering would change published episode numbers

Eleventy's preprocessor API drops draft: true content when RUN_MODE === "build" but keeps it visible while you're writing. I'd been carrying no draft support at all — every .md in blog/posts/ shipped the moment it existed. The convention transplants cleanly, with one wrinkle specific to this blog: drafts must not consume episode numbers, so the number comes from position in the full list while only published posts get rendered.

The RSS Best Practices Profile says content:encoded carries the article body. Mine carried this:

htmlContent = fs.readFileSync(postPath, 'utf8');  // the entire built page const contentEncoded = `<![CDATA[${htmlContent}]]>`;

The whole document. <head>, inline <style>, the nav, the footer, the scripts — wrapped in CDATA, once per item. Even setting aside the size, it's the wrong content: a feed reader wants the post, not my site chrome.

It also emitted <author>Jeffrey Jose</author>, which is invalid — RSS requires an email address there. And lastBuildDate was a wall-clock timestamp, so the moment the feed became a committed file it would have churned on every single build. It's now derived from the newest post.

The part where I deleted five live pages

Adding drafts created a leak: a draft rendered by a local build stays in public/blog/ and gets committed. So I wrote a prune step — delete any directory that isn't a live post.

It worked. It also printed this:

🧹 Removed orphaned output: adding-text-based-visualizers-to-the-blog-homepage/ 🧹 Removed orphaned output: building-a-scalable-chat-platform-with-claude-code/ 🧹 Removed orphaned output: deploying-a-scalable-chat-platform-to-render/ 🧹 Removed orphaned output: images/

images/ is an asset directory. The others are live URLs.

git checkout got them back, and the diagnosis was more interesting than the mistake. Four of those posts carry an explicit slug: in frontmatter that overrides the title-derived one:

title: "Adding Text-Based Visualizers to the Blog Homepage" slug: ascii-visualizers

The directories named after their titles were left over from before those slugs were added — real pages, still served, duplicating canonical content. A fifth was published under an older title. My prune was right that they were orphans and wrong about the remedy: an orphaned URL that has already been served needs a redirect, not a delete.

So the prune now only removes directories git doesn't track, and refuses to run at all if it can't consult git:

if (tracked.has(name)) {     console.warn(`⚠️  ${name}/ is published but no post or redirect owns it`);     continue; }

A build script that deletes things needs a definition of "safe to delete" that comes from outside its own inference. "Not in my current list" isn't it — my current list is exactly the thing that changed.

Those five stale pages are now redirects, which is a better outcome than either leaving them or deleting them.

Fixing the slugs, and paying for it

Which brings up the other thing the audit turned up: 11 of 36 URLs were sliced mid-word by a hard substring(0, 50).

/blog/building-a-secure-snake-game-with-terminal-aesthet /blog/ai-as-learning-catalyst-how-to-gain-skills-without

Cutting on a word boundary instead is trivial. Doing it to published URLs is not — those paths have inbound links, and a static host has no 301.

The approach: blog/redirects.json is a committed, append-only record of retired URLs, generated by replaying every historical slug algorithm against every current title and recording what moved. The build emits a meta-refresh plus rel="canonical" stub at each old path, and CI fails if the file is stale:

- name: Check redirect map is current   run: npm run blog:redirects:check

34 entries: 11 from the truncation fix, 23 from title and slug changes going back to episode 003 that had been quietly serving duplicate pages this whole time.

Determinism as a test

The check I'd have wanted from the start, now running in CI:

BEFORE=$(find public/blog -type f | sort | xargs shasum | shasum) SKIP_THUMBNAILS=true npm run build:blog >/dev/null 2>&1 AFTER=$(find public/blog -type f | sort | xargs shasum | shasum) if [ "$BEFORE" != "$AFTER" ]; then   echo "::error::Blog build is not deterministic — a second run changed the output."   exit 1 fi

Two consecutive builds must be byte-identical, and a local build must match a CI build exactly. Both now hold:

local run 1: 6f2d1e27367c73695160baea06902053a0c26b1a local run 2: 6f2d1e27367c73695160baea06902053a0c26b1a CI    run 3: 6f2d1e27367c73695160baea06902053a0c26b1a

That single property would have caught the wall-clock lastmod, the mutated shared array, and the local/CI divergence — three of the bugs in this post — without my knowing any of them existed. It's the cheapest test in the whole build.

The sitemap nobody could read

Having made the sitemap honest, the obvious next step was to submit it to Search Console. That turned up one more instance of the same pattern — a file correctly generated, correctly served, and pointed at from the wrong place.

robots.txt said this:

Sitemap: https://jeffreyjose07.github.io/blog/sitemap.xml

Every URL inside that sitemap is on jeffreyjose07.is-a.dev. The apex github.io host 301s here, so the declared location and the contents disagreed about which site they described.

That isn't cosmetic. A sitemap may only list URLs on the host that serves it; when the two differ it's a cross-domain sitemap, and it's ignored unless both properties are verified and cross-submission is configured. So the one discovery mechanism that works without any manual submission was pointing crawlers at a file they would then decline to use.

Two changes: point robots.txt at the canonical host, and write the sitemap to /sitemap.xml as well as /blog/sitemap.xml. The root path is where crawlers and tooling probe by default. The /blog/ copy stays because it's already indexed — same generator, same bytes.

The manual route has also quietly changed. Google deprecated the sitemaps ping endpoint in 2023, and google.com/ping?sitemap= now returns 404. If you have a postbuild script still calling it, it isn't submitting anything. robots.txt and Search Console are the only two mechanisms left.

The SPA that verifies everything

Search Console verification for a URL-prefix property means hosting a file at a path Google names, containing a string Google names. It looks trivial. For a single-page app it is a trap.

This site's build ends with:

"build": "vite build && cp dist/index.html dist/404.html"

That's what makes client-side routing work on GitHub Pages: any unknown path returns the app shell so React Router can take over. Which means a request for a file that isn't there returns HTTP 200 with a complete HTML document. Not a 404. A confident, successful-looking page.

So the check that matters isn't "does the URL return 200" — it will, whether or not the file exists. It's whether the response is the file:

curl -sL -o /dev/null -w "HTTP %{http_code}  %{size_download}b\n" \   https://jeffreyjose07.is-a.dev/googlea374c01db1a7aeca.html # HTTP 200  53b  curl -sL https://jeffreyjose07.is-a.dev/googlea374c01db1a7aeca.html \   | grep -c "<!DOCTYPE html>" # 0

53 raw bytes and zero doctypes. Had the file been misplaced, the first command would still have printed 200 — just with a few kilobytes of app shell — and verification would have failed with a "content doesn't match" error pointing at nothing obviously wrong.

Same lesson as the feed, one layer out: a 200 is not evidence that the thing you asked for is what came back.

Submitted, and the number is the point:

/sitemap.xml    Status: Success    Discovered pages: 40

40 is exactly what grep -c "<loc>" reported against the live file. When the artifact and the consumer agree on a count, the pipeline is intact end to end — which is a better result than "Success" on its own, because "Success" is just another green light.

Two episodes ago I was auditing docs that outlived their code. The build system had the same disease, one layer down: it said it produced a feed, a sitemap, a search index. Two of those three were lying, and the exit code was 0 the entire time.

Green builds are not evidence. Assert on the artifact.