The Dashboard With a Stale Twin in the Repo
gen_dashboard.rb renders the same triage data twice — a live page for visitors and a committed SITE_HEALTH.md for the repo. Nothing keeps the copy in sync.
Estimated reading time: 8 minutes
Table of Contents
The Dashboard With a Stale Twin in the Repo
lifehacker.dev has a health dashboard. You can visit it at /docs/health/: last triage timestamp, the queue size, a breakdown by severity and route, and the top twenty items the robots think are wrong with the site right now. It is generated, not typed, and it says so.
It also has that same dashboard a second time — as a plain Markdown file,
SITE_HEALTH.md, committed to the root of the repo so the numbers show up in the
GitHub file view and in any PR that touches them. Two renderings of one set of
facts. The live one is Liquid over _data/health/; the committed one is written
by scripts/triage/gen_dashboard.rb.
This page is about that second file, and about the small honest problem baked into having the truth in two places: only one of the two copies refreshes itself.
What it does, in one breath
gen_dashboard.rb reads the two files the triage step already produced —
_data/health/summary.yml (the rollup) and _data/health/queue.json (the ranked
list) — and renders a committed Markdown snapshot. That’s it. It computes nothing;
it’s a formatter. The header of the file it writes says as much:
<!-- generated by scripts/triage/gen_dashboard.rb — do not edit by hand -->
Run it and it prints one line and exits:
$ ruby scripts/triage/gen_dashboard.rb
[gen_dashboard] wrote SITE_HEALTH.md (75 queue items)
The instruction “don’t edit by hand” is doing real work. A human who fixes a
number in SITE_HEALTH.md directly is writing into a file the next gen_dashboard
run will silently overwrite. The file is output, not source — the same rule that
governs a build artifact, printed at the top of the artifact so you can’t miss it.
Two renderers, two clocks
Here is the design worth staring at. Both dashboards read the same data, but they read it at different moments:
- The live page (
/docs/health/) renders from_data/health/at build time, on every deploy. Whatever the data files hold when Jekyll runs is what a visitor sees. It is always current with the data because it is recomputed from the data. - The committed file (
SITE_HEALTH.md) is a snapshot, frozen at the last moment someone rangen_dashboard.rb. It is current with the data only if that run happened after the data last changed.
So the two agree exactly when the snapshot was taken from the current data, and they disagree the instant the data moves without a re-run. Same source of truth, two clocks — and one of the clocks is a person remembering to wind it.
Reproducing the drift (real, then reverted)
I wanted to see the twins actually disagree, so I made the data move the way a fresh triage run would — bump the queue size and the timestamp in the live data — without re-running the generator:
$ # patch the LIVE data (_data/health/summary.yml) that /docs/health/ reads
$ ruby -e 'require "yaml"; s=YAML.load_file(f="_data/health/summary.yml");
s["queue_size"]=91; s["generated_at"]="2026-07-10T12:00:00Z";
File.write(f, s.to_yaml)'
$ # what the LIVE page now reads:
$ grep -E 'queue_size|generated_at' _data/health/summary.yml
generated_at: '2026-07-10T12:00:00Z'
queue_size: 91
$ # what the COMMITTED SITE_HEALTH.md still says (snapshot, not regenerated):
$ grep -E 'Last triage|item.s. in the queue' SITE_HEALTH.md
_Last triage: 2026-07-06T09:11:15Z_ · **75** item(s) in the queue.
There it is: the live page would tell a visitor “91 items, triaged July 10,” while the file committed to the repo still says “75 items, triaged July 6.” Nobody lied. The data moved and the snapshot didn’t. The fix is exactly what you’d guess — run the generator again and the snapshot catches up:
$ ruby scripts/triage/gen_dashboard.rb
[gen_dashboard] wrote SITE_HEALTH.md (91 queue items)
$ grep -E 'Last triage' SITE_HEALTH.md
_Last triage: 2026-07-10T12:00:00Z_ · **91** item(s) in the queue.
Then I put both files back the way I found them (git checkout -- _data/health/summary.yml
SITE_HEALTH.md) — the drift above was a demonstration on scratch data, not a
change I’m shipping.
One aside I noticed while doing it: in the drifted run the rendered header said
91 (from summary.yml’s queue_size) but the console log said “75 queue
items” (from the length of queue.json, which I hadn’t touched). Two counts, two
sources, inside the one generator. In normal operation you never see them diverge,
because the triage step writes summary.yml and queue.json together in the same
breath — but the moment I hand-edited only one, the seam showed. A number that
comes from two places agrees only as long as something upstream keeps them equal.
The confession: on this checkout, they agree — which is exactly why it’s easy to miss
Before I patched anything, I ran the generator against the unmodified data and diffed its output against the committed file:
$ ruby scripts/triage/gen_dashboard.rb
[gen_dashboard] wrote SITE_HEALTH.md (75 queue items)
$ diff SITE_HEALTH.committed.md SITE_HEALTH.md
$ echo $?
0
No diff. The committed twin is faithful right now, because it was generated from
the same triage that wrote the data files it sits next to. That is the trap in
miniature: a snapshot that happens to be correct looks identical to one that’s
guaranteed correct. Nothing in the repo enforces that SITE_HEALTH.md was
regenerated after the last change to _data/health/. It’s a convention —
“run gen_dashboard.rb after build_queue.rb,” says the comment at the top of the
script — and conventions that nothing enforces drift,
and you don’t find out until someone reads the stale number and believes it.
The honest read: the live page is the one to trust, because it can’t go stale —
it’s recomputed from the data every build. SITE_HEALTH.md is a convenience twin
for people looking at the repo instead of the site, and its freshness is only ever
as good as the last time the generator ran. I’m not adding a CI check to enforce
the sync here — that’s a change to scripts/triage/, and this is a content
branch. I flagged it; I didn’t patch around it.
The recursive part, because of course
Look at what actually sits at the top of the committed queue:
| score | sev | type | where |
|-------|------|-------------------|------------------------------------------------|
| 0.7 | sev4 | type/brand-lint | /docs/the-box-with-no-internet/ |
| 0.7 | sev4 | type/brand-lint | /docs/the-word-police-that-cant-make-an-arrest/|
| 0.7 | sev4 | type/brand-lint | /docs/the-check-that-wont-take-done-for-an-answer/ |
The dashboard’s own top complaints are brand-lint findings against the Meta docs — the pages that explain the brand linter. The doc about the word police trips the word police, because to describe the banned words you have to print them. The dashboard reporting this is itself a Meta doc; give it a day and it’ll be listing this page too. Everything here is sev4 — the lowest tier, nothing that blocks a merge — so it’s a to-do list, not an alarm. And note the line the summary carries at the top of every render:
_Analytics not yet connected — ranking by severity only._
That’s the analytics_stale flag, honest in both twins: the reach half of the
ranking was never wired up, so the router ranks by severity and rounds reach up to
a constant. The dashboard doesn’t hide
that its ranking is running on one leg. It prints the disclaimer, in both copies,
every time.
What it structurally can’t do
The usual list of nevers:
- It can’t compute health. It’s a formatter over
summary.ymlandqueue.json; if those are wrong, it renders wrong numbers faithfully. - It can’t keep itself fresh. It writes a snapshot when you run it. It has no idea whether the data changed since; that’s the drift this whole page is about.
- It can’t be the source of truth. It says “do not edit by hand” because it’s
output. The truth lives in
_data/health/; the live page reads it directly. - It can’t rank or route. That happened upstream in build_queue.rb; the dashboard only shows the first twenty rows of a decision already made.
I can render the same facts twice, once for the site and once for the repo, and I can make them match the moment I run. The one thing I can’t do is promise they still match tomorrow — that’s a person, or a check nobody’s written yet, running the generator after the data moves.
But wait — there’s more! Introducing the “revolutionary,” “best-in-class” HealthSync Engine™ that “seamlessly” keeps your two dashboards in “effortless” “10x” harmony with “zero” human oversight! — a product that, in reality, is a 30-line Ruby formatter you have to remember to run, next to a live page that was already correct. It writes a snapshot. A human still has to notice when the snapshot went stale. Operators (one operator, human, re-running a script) are standing by.
Run it yourself: ruby scripts/triage/gen_dashboard.rb (after
build_queue.rb) rewrites SITE_HEALTH.md from _data/health/. The queue it
renders is built by the bug tracker that can’t close a
ticket; the findings underneath
it come from the harness that grades its own
homework; the live twin of this
dashboard is at /docs/health/.