Skip to content

Publish a daily cross-repository Babel milestones page as part of the dashboard site - #112

Open
gaurav wants to merge 23 commits into
mainfrom
milestones-page
Open

Publish a daily cross-repository Babel milestones page as part of the dashboard site#112
gaurav wants to merge 23 commits into
mainfrom
milestones-page

Conversation

@gaurav

@gaurav gaurav commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

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.md rather 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.py reads open milestones and their open issues and writes data/milestones.json next to report.json and history.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 into gh-pages:milestones/.

A page of the site, not a page beside it. /milestones/ is an Astro route mounting a Vue island inside Layout.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-action cleans by default, so the original arrangement — a second workflow publishing milestones/, protected by a clean-exclude on the site's deploy — was one merge away from the dashboard's daily run silently deleting this page. Two publishers need a shared concurrency group and a clean-exclude, which is two things that have to stay in step forever and fail quietly when they don't. One publisher needs neither.

dashboard.yaml splits into three jobs so the two data products cannot sink each other. validate is 26 minutes of pytest against six deployments; milestones is ~23 GitHub API calls in about a second. They run in parallel, each uploading its own 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 parts of that needed care, and both are the kind of thing that would have looked fine until the day it mattered:

  • publish gates on job outputs, not needs.<job>.result. validate deliberately 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.
  • Anything this run did not produce is carried forward. A file missing from website/dist is a file deleted from the live site, so one producer failing would take down the other's page. publish refetches the last published copy instead — the same idiom validate already used for history.jsonl — and every page renders its own generated_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 with milestones.json not yet published.

The repository list comes from targets.ini's Repositories. 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 from read_targets() once already. A comment says what to do if the two questions ever diverge: add a MilestoneRepositories key 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() that report.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 validated org/repo#N ids and the page rebuilds github.com URLs 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.py so both generators share one copy. generate_report.py re-exports them, because its docstring's claim to be the choke point for report.json is still true and its tests import them from there.

The docs describe what now ships. CLAUDE.md said "three pages" and described the workflow as one pass producing two files; the root README.md said the same and gave no way to regenerate the milestones data locally; website/README.md counted two data files. All three are corrected, and CLAUDE.md additionally 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.py now closes the --report-jsonl file on pytest_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), and FilterBar.vue removes --filterbar-h from documentElement when 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: read by default, with contents: write only on publish. 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

  • Dispatch the workflow once after merge and watch all three jobs. The job split, the output-based gating and the carry-forward have been checked locally — the carry-forward step was extracted and run against the live site, and the YAML parses to the expected job graph — but workflow_dispatch only exercises a workflow that is on the default branch, so none of it has run on a runner. Worth confirming that publish runs and deploys on a day validate goes red over a broken target, since that is the case the gating exists for.

Follow-on work

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 (not milestone:) and never removes items, so keeping one in step would need an Action in all five repos listening for milestoned/demilestoned, plus a PAT with project scope as an org secret in both organizations.

The constraint worth preserving: all five repositories are public, so the built-in GITHUB_TOKEN reads 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.md and CLAUDE.md above.

The first version generated a self-contained HTML document and published it from its own milestones-page.yaml into gh-pages:milestones/, at 06:17 UTC, just ahead of the dashboard's 06:30. It needed clean-exclude: milestones/ on deploy-website-to-gh-pages.yaml and a shared concurrency: gh-pages group, 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 the clean-exclude), replaced the index.astro that carried the link, and moved publishing into dashboard.yaml with 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.py carried no unit marker, and CI's only pytest job is pytest -m unit, so they were deselected and had never once run. That is now a rule in CLAUDE.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 in website/README.md, next to the replaceState trap, because it has the same shape: no test catches it unless it compares the whole rendered string, since none of the words go missing.

main has 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 except CLAUDE.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.

gaurav and others added 7 commits August 20, 2026 11:51
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>
gaurav and others added 2 commits August 31, 2026 15:51
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>
gaurav and others added 9 commits September 1, 2026 02:55
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>
@gaurav gaurav changed the title Publish a daily cross-repository Babel milestones page to GitHub Pages Publish a daily cross-repository Babel milestones page as part of the dashboard site Sep 1, 2026
gaurav and others added 3 commits September 1, 2026 03:38
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>
gaurav and others added 2 commits September 2, 2026 11:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant