Publish a daily cross-repository Babel milestones page as part of the dashboard site - #112
Open
gaurav wants to merge 23 commits into
Open
Publish a daily cross-repository Babel milestones page as part of the dashboard site#112gaurav wants to merge 23 commits into
gaurav wants to merge 23 commits into
Conversation
Milestones cannot span repositories, so tracking commitments across the five Babel repos in two organizations means checking five separate milestone pages. This generates a single chronological page from all of them, ordered by due date with undated milestones last, and flags past-due ones. The legacy priority-bucket milestones (Immediate, Needed soon, ...) are undated and never close, so they are split into their own section rather than sorted among the real deadlines. Reads only milestones and issues: every repository involved is public, so this runs on a workflow's built-in GITHUB_TOKEN with no secrets to configure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploys to the milestones/ subfolder rather than the root of gh-pages, so it does not disturb the Astro site that deploy-website-to-gh-pages.yaml publishes there on release. The two have unrelated cadences, hence a separate workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oard Records the constraint that keeps this cheap — all five repositories are public, so no PAT is needed — and what breaks it, namely reading fields off a GitHub Project. Also lists what the first version deliberately leaves out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy-website-to-gh-pages.yaml publishes website/dist to the root of gh-pages, and github-pages-deploy-action cleans the target by default, so every release would delete the milestones/ directory published by milestones-page.yaml. The page would then be missing until the next daily run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anything published to gh-pages outside website/dist is deleted by the next release deploy unless it is listed in that job's clean-exclude. That is not visible from either workflow alone, so note it where someone adding a second published artifact will look. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue titles are arbitrary user text, so the escaping test guards a real correctness path: an unescaped angle bracket in a title would break the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site deployed only on release, so it could lag main indefinitely; it now also deploys on push to main and on demand. That makes an overlap with the daily milestones deploy far more likely, and both jobs push to gh-pages, so they now share one concurrency group instead of two independent ones. Also links the milestones page from the site index, since nothing pointed at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 20, 2026
Open
Every piece of this PR that connected the milestones page to the website is invalidated by #120, which replaces the site with a daily dashboard: - milestones-page.yaml deployed to gh-pages:milestones/ on its own cron, relying on `clean-exclude: milestones/` in deploy-website-to-gh-pages.yaml to survive the site deploy; - #120 deletes deploy-website-to-gh-pages.yaml outright, and its own deploy cleans the whole branch, so that protection is gone and the next daily dashboard run would delete the milestones page; - the "Project status" link was added to website/src/pages/index.astro, which #120 rewrote entirely. All three would have merged without a conflict and then quietly done the wrong thing, which is the worst available outcome. They are removed so the wiring has to be written again deliberately, against what the site actually looks like by then. The tool, its tests and docs/milestones-page.md are untouched, and the deleted wiring is in this branch's history if a detail is wanted. docs/milestones-page.md now carries what a rebuilt version has to account for, including the preferred direction: generate the page inside dashboard.yaml and let the single existing deploy publish it, so that two Actions never compete over gh-pages at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's only pytest job is `uv run pytest -m unit -v`, and these six carried no marker, so every one of them was deselected: the file has never run in CI. They build milestones out of SimpleNamespace and touch no network, which is exactly what the marker is for. Verified by merging main in and running the file both ways: 6 passed without a marker expression, 6 deselected under `-m unit`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Aug 31, 2026
A unit test with no `pytestmark = pytest.mark.unit` is silently deselected by CI's only pytest job, so the file looks like a passing suite while testing nothing. #112 had six tests in that state, and nothing said so. And the site is served under <base href="/babel-validation/">, which is why a relative URL handed to history.replaceState rewrites the address bar to a different page. The trap is that no test can catch it: vitest mounts components at / with no <base>, so the relative form works there and fails only in production. That belongs next to the note about how the components are tested, not only in the one comment beside the call. Also drops the manual curl block added to website/README.md earlier today, which duplicated the `npm run fetch-data` script that already exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note this replaces described the old arrangement: gh-pages had more than one publisher, and the way to survive that was to get yourself into deploy-website-to-gh-pages.yaml's clean-exclude. Both halves are now wrong — that workflow was deleted with the old site, and dashboard.yaml deploys to the root of gh-pages and cleans by default. More to the point, the advice was pointing the wrong way. A second publisher plus a clean-exclude plus a shared concurrency group is three things that have to stay in step forever, and the milestones page is the near-miss that proves it: it published into gh-pages:milestones/ from its own workflow, and the dashboard's deploy would have deleted it on the first daily run after they both landed. So the rule is now the one that has no moving parts — a new page joins the existing pipeline as one more data file, one more Astro route and one more job, and nothing else may write that branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
generate_report.py's docstring calls it the choke point between untrusted input and a public file, and the four things that make it one — truncate-and-escape, the org/repo#N regex, the two allowlist checks — were written for report.json and live in that module. A second generator is about to publish GitHub issue titles to the same website and needs exactly the same guarantees, and the way that goes wrong is a near-copy that is subtly weaker and drifts from this one. So they move to sanitize.py and both import them. generate_report.py re-exports the names it used to define, because the module docstring's claim is still true of report.json and its tests import them from there. No behaviour change: the functions are moved verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It wrote a self-contained HTML page with its own inline CSS, because it was built to be published by its own workflow into gh-pages:milestones/. That arrangement is gone: one workflow owns gh-pages now, and a page that arrives as a foreign HTML document gets none of the site — no nav bar, no theme, no dark mode, and no share in the conventions the three dashboard pages already agreed on. So render(), _milestone_html() and _issue_html() go, and build_milestones() takes their place: the same data, as JSON, written next to report.json and history.jsonl for a Vue island to render. The parts that encode real decisions are untouched — sort_key's datetime.date.max for undated milestones, is_bucket and BUCKET_TITLES for the legacy priority buckets, and collect()'s traversal. Two things the move makes possible rather than merely tidy: Titles, labels and assignee logins are written by anyone with a GitHub account and land on a public page, so they now go through the same sanitize() that report.json's text does — escaped rather than stripped, and truncated to lengths a milestone title has no business exceeding. html.escape() was correct for HTML and says nothing about ANSI escapes or bidi overrides. Issue ids are emitted as org/repo#N, validated against targets.ini's allowlist, never as a URL. The page rebuilds the link from the validated parts with the issueLink() the results table already uses; an issue that fails the check simply has no id, so there is nothing to build a link from. The repository list comes from targets.ini's Repositories rather than a hardcoded copy — the workflow's target loop already drifted from read_targets() once, and this is the same trap. read_repositories() is new because read_targets() lowercases for comparison and the API call needs the case back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page wants to link a milestone title to its GitHub milestone page, and the obvious way to do that — interpolate milestone.repo into a URL — is exactly what the report rules forbid. repo is sanitized, which makes it safe to *display*; it says nothing about whether it names a repository we trust. So the generator emits the milestone as the same validated org/repo#N token the issues already use, and milestoneLink() re-checks it against the same regex before building /milestone/N, the way issueLink() builds /issues/N. A milestone in a repository the allowlist does not name simply has no id, and its title renders unlinked. fetchReport's body becomes fetchJson, since a second page now needs it and the name should say what it does. fetchReport stays as an alias rather than a rename, so nothing that already calls it has to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fourth page, built like the other three: a thin Astro route mounting one Vue island inside Layout.astro, fetching one JSON file from data/. It gets the nav bar, the theme and dark mode for free, which is the whole reason for folding the generator into this site rather than publishing a standalone HTML file next to it. Two sections rather than one list, honouring the flag the generator sets: the legacy priority buckets have no due date and never close, so sorting them among real deadlines would either bury the deadlines or dress the buckets up as ones. The card is its own component. Milestones.vue fetches and splits; MilestoneCard renders. That is also the file where the untrusted-text rules have to hold, so it is easier to review as one small file than as a section of a large one. The summary line stamps the date the data was generated. That is not decoration either: the publish job is about to gain a carry-forward that republishes the last good copy when a run fails to regenerate this file, and without the date there would be nothing on the page to distinguish today's milestones from last week's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h other One job did everything: 26 minutes of pytest against six deployments, then the report, then the build, then the deploy. Adding the milestones data to that job would have made a page that is a handful of GitHub API calls hostage to the validation run — a hung environment, a 45-minute timeout, a broken target — for no reason other than that they happen to publish to the same branch. So `validate` and `milestones` run in parallel, each uploading its own finished data file, and `publish` assembles what arrived and makes the single write to gh-pages. `milestones` is never on the critical path, so this costs an extra checkout and a uv sync and no wall clock. Two things needed care. `publish` gates on job *outputs*, not on `needs.<job>.result`. `validate` deliberately fails itself when a target's run broke — that is the whole point of the step added in 5f90b3b, and it happens on ordinary days — so a result-based condition would skip the download of a report that had generated perfectly well and quietly publish yesterday's instead. The output is set immediately after the generator succeeds, several steps before that failure, and Actions preserves outputs set before a later step fails. Expect a red `validate` beside a green `publish` on those days; that is the same "publish what worked, then raise the finding" ordering the single job had, made visible. And the deploy cleans gh-pages, so a file missing from website/dist is a file deleted from the live site: one producer failing would take down the other's page. `publish` therefore refetches the last published copy of anything this run did not produce, the same idiom validate already uses for history.jsonl. Each page renders its own generated_at, so a carried-forward file reads as stale rather than as current, and if even the live copy is missing — the first run of a new data file — the page falls back to its own load-error state. Permissions drop to contents:read by default, with contents:write only on `publish`. The two producer jobs run untrusted input and no longer hold a token that can push anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc's Publishing section opened with "this has to be rebuilt, and it is deliberately not written yet", and then described the arrangement that was removed. Both are now history, so it says what shipped instead — and keeps the near-miss, because "two publishers on gh-pages plus a clean-exclude" is a thing someone will reach for again, and its failure mode is a page that quietly disappears rather than a build that goes red. website/README.md said the site renders two data files. Three, and any of them may be a carried-forward copy from an earlier run, which is why every page renders its own generated_at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a run against the live API found. The generator assumed its output directory existed, which it does not on a fresh checkout or in the workflow; generate_report.py already creates --out-dir, so this does the same and the workflow's mkdir goes away rather than being a second mechanism for it. And the summary line read "3 past due· as of 2026-09-01": Vue condenses whitespace at a <template> boundary, so a trailing " · " inside the conditional was silently dropped. Separators now lead each part instead of joining them, which has no boundary to be eaten at. Verified against the live API: 18 milestones, 313 open issues, 11 buckets, 3 past due, in ~23 calls, and the component renders all 331 links as github.com URLs built from validated ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 1, 2026
CLAUDE.md still said "three pages" and described dashboard.yaml as one pass of pytest-then-generate_report. It is four pages and three jobs, and the two things about that arrangement that are easy to undo are now written down where someone restructuring the workflow will read them: publish gates on job outputs rather than needs.<job>.result, because validate deliberately fails itself on an ordinary day; and the deploy cleans the branch, so a missing data file is a deleted page. #122 will restructure this same workflow, which is exactly the occasion for reintroducing both. website/README.md gains the whitespace trap this session spent time on. Vue removes a whitespace-only text node at the end of a <template> block, so a trailing separator inside a v-if is silently dropped: the milestones summary read "3 past due· as of 2026-09-01", which looks like the generator emitting a bad string rather than the template eating a space. It sits next to the replaceState note because it has the same shape — no test catches it unless it compares the whole rendered string, since none of the words are missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_repositories exists for one reason — read_targets lowercases the repository list, because an allowlist is only ever asked "is this in you?", while the GitHub API call and the rendered label need the case back — and nothing asserted that. A lowercased name would still resolve on github.com, so the symptom would be a wrongly-cased repository label rather than a failure: the kind of bug nobody files. Lowercasing read_repositories now fails the test. milestoneLink is a link builder, and reportData.test.js's own comment says the link builders are the one piece of that module worth testing directly, because they turn untrusted report text into URLs. It gets the same rejection cases issueLink has; relaxing its regex to /(.*)#(.*)/ now fails. Both were checked against mutated code first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard section described the workflow as one pass producing two files. It is three jobs producing three, and the README is where someone looks to regenerate the site locally — so it now also gives the one command for the milestones data, which needs a token but no test run and no scopes beyond the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Sep 2, 2026
The only pytest job on main is `uv run pytest -m unit -v`, so an unmarked test file is silently deselected: 30 tests that look like coverage and execute never. This has already happened once in this repo (#112 merged six tests that had never run), which is why the root CLAUDE.md calls it out. This branch predates the marker's introduction on main, so register `unit` in pyproject.toml too — verbatim from main, so the two merge cleanly. Verified: `pytest -m unit` now selects 30 and deselects 25. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 tasks
pytest_configure opens it and nothing ever closed it; it was left to interpreter shutdown. Nothing is lost today, because every record is flush()ed as it is written — which is exactly what makes this worth fixing rather than shrugging at. The flush is per test phase on a file that gets thousands of them, so it is the obvious thing for someone to remove when this run feels slow, and the day that happens the last records of a run vanish with no error and the dashboard quietly under-reports. Noticed reviewing #120; not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beforeUnmount disconnected the ResizeObserver but left the custom property it had been writing. That property lives on documentElement, which outlives the component: Results.vue unmounts the bar on the load-error path, and the stale height then holds the table header's sticky offset down a page that no longer has a bar above it. Narrow — the load-error retry is the only way to reach it — but the fix is one line, and a component that writes to a shared global should be the thing that cleans it up. Noticed reviewing #120; not caused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Milestones are the commitment mechanism across the Babel repositories, but they cannot span repositories — so "what have I actually committed to?" currently means visiting five separate milestone pages across two organizations. This adds a page to the dashboard site that merges them into one chronological list, regenerated daily.
Closes no issue of its own — the need was recorded in
docs/milestones-page.mdrather than as a ticket.Live numbers from a run against the API this morning: 18 open milestones, 313 open issues, 11 of the milestones priority buckets rather than releases, 3 past due.
What's here
A generator, not a page-builder.
src/babel_validation/tools/generate_milestones.pyreads open milestones and their open issues and writesdata/milestones.jsonnext toreport.jsonandhistory.jsonl. The first version of this PR wrote a self-contained HTML file with its own inline CSS, because it was going to be published by its own workflow intogh-pages:milestones/.A page of the site, not a page beside it.
/milestones/is an Astro route mounting a Vue island insideLayout.astro, exactly like the three dashboard pages: it gets the nav bar, the theme, dark mode and the load-error state for free. Releases first, sorted by due date; the legacy priority buckets (Immediate,Needed soon, …) in their own section below, because they have no due date and never close, so sorting them among real deadlines would either bury the deadlines or dress the buckets up as ones.One workflow still owns
gh-pages. This is the point of the whole restructure.JamesIves/github-pages-deploy-actioncleans by default, so the original arrangement — a second workflow publishingmilestones/, protected by aclean-excludeon the site's deploy — was one merge away from the dashboard's daily run silently deleting this page. Two publishers need a sharedconcurrencygroup and aclean-exclude, which is two things that have to stay in step forever and fail quietly when they don't. One publisher needs neither.dashboard.yamlsplits into three jobs so the two data products cannot sink each other.validateis 26 minutes of pytest against six deployments;milestonesis ~23 GitHub API calls in about a second. They run in parallel, each uploading its own data file, andpublishassembles what arrived and makes the single write togh-pages.milestonesis never on the critical path, so this costs an extra checkout and auvsync and no wall clock.Two parts of that needed care, and both are the kind of thing that would have looked fine until the day it mattered:
publishgates on job outputs, notneeds.<job>.result.validatedeliberately fails itself when a target's run broke — that is what the step added in 5f90b3b is for, and it fires on ordinary days. A result-based condition would therefore skip the download of a report that had generated perfectly well, and quietly publish yesterday's instead. The output is set immediately after the generator succeeds, several steps before that failure, and Actions preserves outputs set before a later step fails.website/distis a file deleted from the live site, so one producer failing would take down the other's page.publishrefetches the last published copy instead — the same idiomvalidatealready used forhistory.jsonl— and every page renders its owngenerated_at, so a carried-forward file reads as stale rather than as current. If even the live copy is gone, which is the case on the first run of a new data file, the page falls back to its own load-error state; verified by running the step against the live site withmilestones.jsonnot yet published.The repository list comes from
targets.ini'sRepositories. It is already exactly this family of repositories, it is checked-in config the untrusted-input rules treat as trusted, and the workflow's target loop has drifted fromread_targets()once already. A comment says what to do if the two questions ever diverge: add aMilestoneRepositorieskey that falls back to this one, not a hardcoded list.Milestone and issue titles are untrusted, and are treated as such. They are written by anyone with a GitHub account and land on a public page, so they go through the same
sanitize()thatreport.json's text does — escaped rather than stripped, and truncated.html.escape()was right for HTML and says nothing about ANSI escapes or bidi overrides. Links are never emitted as URLs: the generator emits validatedorg/repo#Nids and the page rebuildsgithub.comURLs from the captured parts, so an issue or milestone in a repository the allowlist does not name simply renders unlinked. Issue bodies are never read.Those primitives moved to
src/babel_validation/tools/sanitize.pyso both generators share one copy.generate_report.pyre-exports them, because its docstring's claim to be the choke point forreport.jsonis still true and its tests import them from there.The docs describe what now ships.
CLAUDE.mdsaid "three pages" and described the workflow as one pass producing two files; the rootREADME.mdsaid the same and gave no way to regenerate the milestones data locally;website/README.mdcounted two data files. All three are corrected, andCLAUDE.mdadditionally records the two properties of the new workflow that a restructure would silently undo — the output-based gating and the cleaning deploy — because #122 is a restructure of exactly this workflow.Two unrelated cleanups ride along, both noticed while reviewing #120 and both one line:
tests/conftest.pynow closes the--report-jsonlfile onpytest_unconfigure(nothing is lost today because every record is flushed, which is what makes it worth fixing — the flush is the obvious thing to remove for speed), andFilterBar.vueremoves--filterbar-hfromdocumentElementwhen it unmounts, so the load-error path stops holding the table header's sticky offset down a page with no filter bar on it.Permissions drop.
contents: readby default, withcontents: writeonly onpublish. The two jobs that run untrusted input no longer hold a token that can push anything.What it produces
44KB of JSON, against
report.json's 1.8MB — 18 open milestones and 313 open issues on the run this was verified against.Tests: 14 new Python unit tests and 9 new vitest specs. The six Python tests that shipped on this branch originally were rewritten rather than kept: three of them tested
render(), which no longer exists.What it deliberately does not do
Before merging
workflow_dispatchonly exercises a workflow that is on the default branch, so none of it has run on a runner. Worth confirming thatpublishruns and deploys on a dayvalidategoes red over a broken target, since that is the case the gating exists for.Follow-on work
Priority:labels. Publish a daily cross-repository Babel milestones page as part of the dashboard site #112's note said this was blocked on a label scheme that did not exist; it does, and is on 162 of the 313 open issues (Priority: High66,Medium55,Low41), alongsideSize: XS/S/M/L. Ordinary UI work now rather than a decision, and the page is correct without it.BUCKET_TITLESonce the legacy priority-bucket milestones are cleaned up. Still 11 of the 18 open milestones, so blocked on the cleanup rather than on the code.dashboard.yamlannotated. Not caused by this PR, but this PR is what surfaced it.StatusMatrixcounts the sharedci/ci-esNodeNorm deployment twice, which can turn a genuine 3–3 promotion split into a 4–2 "majority" and shade the wrong half of the row. Pre-existing; noticed reviewing Replace the website with a daily cross-environment test dashboard built around promotion drift #120.Tests, sogh run list --workflow Testsis ambiguous today, and Make babel_validation a top-level import; add ruff and offline unit tests to CI #106 would merge a second one alongside the first rather than replace it. An earlier revision of this description recorded this as resolved by time, which was wrong:test.ymlbeing offmainis not the same as it being gone./results/link goes stale:?page=is clamped for rendering but only written back to the URL on a user edit, so?page=9999copied straight out of the address bar stays?page=9999.Why generated rather than a GitHub Project
A cross-repo project board was the obvious alternative. A board is a second record of commitment that has to be kept in step with the milestones by hand, and drifts as soon as that stops happening; this page is derived, so the milestone is the only input and there is nothing to sync. A board also can't answer the question that matters most from outside — roughly when someone's issue will be looked at — whereas a public URL can.
Automating a board is also expensive: the built-in auto-add filter supports only
is/label/reason/assignee/no(notmilestone:) and never removes items, so keeping one in step would need an Action in all five repos listening formilestoned/demilestoned, plus a PAT withprojectscope as an org secret in both organizations.The constraint worth preserving: all five repositories are public, so the built-in
GITHUB_TOKENreads them across both orgs with no secrets to configure or rotate. Reading fields off a GitHub Project would break that. Priority and component therefore live in labels — which, as above, they already do.History — the HTML-page version, the wiring that had to be removed, and the rebuild. Kept for anyone tracing why a particular line looks the way it does; the durable conclusions are in
docs/milestones-page.mdandCLAUDE.mdabove.The first version generated a self-contained HTML document and published it from its own
milestones-page.yamlintogh-pages:milestones/, at 06:17 UTC, just ahead of the dashboard's 06:30. It neededclean-exclude: milestones/ondeploy-website-to-gh-pages.yamland a sharedconcurrency: gh-pagesgroup, because that workflow deployed to the root of the branch and cleaned by default.#120 invalidated all three pieces at once: it deleted
deploy-website-to-gh-pages.yaml(and with it theclean-exclude), replaced theindex.astrothat carried the link, and moved publishing intodashboard.yamlwith a deploy that also cleans. Every one of those would have merged without a conflict and then deleted this page on the next daily run, so the wiring was removed from this branch before #120 merged rather than left to break quietly. That is why this PR sat for a while with a tool, some tests and a doc saying "not currently published".Also fixed along the way: the six tests in
test_milestones_page.pycarried nounitmarker, and CI's only pytest job ispytest -m unit, so they were deselected and had never once run. That is now a rule inCLAUDE.md, with this file as the cautionary example.Two things the live run found after the rebuild, both committed separately: the generator assumed its output directory existed, which it does not on a fresh checkout or in the workflow; and the summary line rendered "3 past due· as of 2026-09-01", because Vue condenses whitespace at a
<template>boundary and silently dropped a trailing separator. The second is now written down inwebsite/README.md, next to thereplaceStatetrap, because it has the same shape: no test catches it unless it compares the whole rendered string, since none of the words go missing.mainhas since advanced past the merge commit on this branch — #130 and the seven commits behind it reworked the Google Sheet download. They touch no file this branch touches exceptCLAUDE.md, in a different section, and the PR's checks run against the merge result and are green, so no merge was made purely to prove that.