Skip to content

feat: serve the flashtrace JSON Schemas verbatim#9

Merged
MentorFilou merged 16 commits into
mainfrom
feat/serve-schemas
Jul 21, 2026
Merged

feat: serve the flashtrace JSON Schemas verbatim#9
MentorFilou merged 16 commits into
mainfrom
feat/serve-schemas

Conversation

@MentorFilou

@MentorFilou MentorFilou commented Jul 20, 2026

Copy link
Copy Markdown
Member

Publishes the machine-readable JSON Schemas the tool repo ships under schemas/, built from the same release checkout /docs/ already comes from — so the site cannot disagree with itself about which version it serves.

This is the serving half of #8, split out so it can merge quickly. The generated documentation page per schema follows in a separate PR.

What lands

URL What
/schemas/report/v0.json verbatim copy of the release's schemas/report/v0.json
/schemas/report/latest.json byte copy of the highest version

No page, no nav entry, no styling — nothing user-visible changes.

Decisions worth reviewing

The schemas never touch the markdown pipeline. Written byte-for-byte and served as-is. The strings inside them ($id, $ref, $schema) are identifiers, not navigation, and the docs link rewriter would corrupt them.

latest.json is a real file, and its $id is not rewritten. It keeps "$id": ".../schemas/report/v0.json". JSON Schema allows the retrieval URI to differ from $id, which is the point: a copy fetched from latest.json must still identify the version it actually is, or every vendored copy would claim to be current. A redirect was not an option — GitHub Pages has no server-side redirects, and a meta-refresh means nothing to a JSON fetch.

Only schemas/<name>/v<N>.json is served. Exactly one directory deep, with an explicit format allowlist holding just json today. The build writes the files it validated rather than copying the folder, so nothing reaches the site that was not checked — not a stray file, not a symlink, not a format nobody decided to support. Adding xml later is a one-word change.

A broken schema does not stop the deploy. Both CI and deploy build from the newest flashtrace release, so making one bad file fatal meant one bad release could stop the site deploying at all — docs included — and leave it stale until upstream cut a fix. That blast radius was wrong: refusing to publish a broken schema is right, refusing to publish the docs because a schema is broken is not. Unservable files are skipped individually and everything that validated still ships.

Skips are surfaced as a tracking issue, not just a log line. A skip nobody sees is a silent outage of that schema, and a green deploy is exactly where nobody looks. Every run annotates each skipped file; deploys additionally maintain one issue. PR builds annotate but never file, since a PR should not open an issue about upstream content it did not touch. The step is continue-on-error — reporting a problem must never become a worse problem than the one reported.

That issue is a live view, not a log. Its body always shows the most recent build's skips, rewritten in place; each change also appends a comment saying what moved — which files stopped being served, which came back, which now fail for a different reason. Opening the issue tells you what is wrong now without scrolling; the comments keep the history for anyone who wants it. The body carries the problem set it was written from in a comment marker, which is what lets the next run tell an unchanged set from a changed one and diff the two — no state stored anywhere else to fall out of step. It opens on the first bad build, stays completely silent while nothing changes, and closes itself on the first clean one, rewriting the body first so nobody arrives from the close notification onto a stale table.

Files that are plainly the tool repo's own stay silent. A README.md explaining the folder to someone reading that repo, dotfiles, an empty placeholder folder: not served, not reported. What is reported is a name that was reaching for the layout and missed — v1.2.json, report-v0.json, a symlink where a schema belongs — because a typo that unpublishes a schema is otherwise indistinguishable from a file that was never meant to be one.

Version files are re-copied every build. schemaVersion only moves on breaking changes, so v0.json is not write-once — it keeps receiving additive updates within its version.

A missing schemas/ only warns. It is absent until the release that introduces it, and CI builds against the latest published release. An empty schemas/ warns too: the folder can land upstream a release before the first schema inside it does.

Verified locally

Built against a checkout that has schemas/ (the feat-json-report upstream worktree), one that does not, and a synthetic one carrying every problem class at once.

  • /schemas/report/v0.json is byte-identical to the upstream file (cmp)
  • /schemas/report/latest.json is byte-identical to v0.json (cmp)
  • $id in both reads https://flashtrace.github.io/schemas/report/v0.json
  • without schemas/: warns, skips, builds 13 pages as before, no dist/schemas/
  • synthetic checkout: malformed JSON, an .xml version, v1.2.json, an upstream latest.json, a file outside a name folder, and a directory nested too deep are each reported and skipped — the build exits 0 and still serves the five good files
  • a broken v10.json leaves latest.json on v9.json rather than on nothing, and the skip says why
  • v10 sorts above v9; README.md and an empty folder are silently ignored
  • the reporting script's decisions are covered end to end against a fake gh: annotate-only never calls gh at all; open / update-in-place / stay-silent / close each fire on the right state; the body is re-stamped with the new set and the comment names exactly what moved; closing rewrites the body before changing state; a body whose state marker was deleted degrades to "cannot diff" rather than to a wrong diff; and a reason containing a pipe, a newline or --> breaks neither the table nor the embedded state

Still needs checking after the first deploy

Two response-header requirements cannot be verified before the site is live, and neither is controllable from this repo — GitHub Pages has no _headers support:

curl -I https://flashtrace.github.io/schemas/report/latest.json

Expecting 200, Content-Type: application/json, and Access-Control-Allow-Origin: *. Pages is expected to do both by default, but that is worth confirming rather than assuming — third-party validators fetching the schema by $id depend on the CORS header. If either is wrong we need a different hosting path for /schemas/.

Sequencing

schemas/ is not in a flashtrace release yet, so this merges safely now and stays dormant: the build warns and skips. The schemas appear on the site only after a flashtrace release containing them — a merge to the tool repo's main is not enough, since both CI and deploy build from a release tag.

#10 is stacked on this branch and reads versions[].json, which this PR removes; it needs to parse the bytes where it uses the document.

🤖 Generated with Claude Code

Copies the tool repo's schemas/ into dist/ byte-for-byte and adds a
generated latest.<format> alias per schema directory.

The files never touch the markdown pipeline: consumers fetch them by
$id, and the docs link rewriter would corrupt the identifiers ($id,
$ref, $schema) inside them.

latest.json is a real file rather than a redirect - GitHub Pages has no
server-side redirects and a meta-refresh means nothing to a JSON fetch.
Its $id is not rewritten, so a copy fetched from latest.json still
identifies the version it actually is.

schemas/ is absent until the release that introduces it, so a missing
folder only warns; CI and deploys build from the latest published
release and must keep working. Once the folder exists, anything wrong
inside it is fatal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MentorFilou and others added 7 commits July 21, 2026 00:00
Discovery walked schemas/ to any depth, accepted any extension, and the
build then cpSync'd the whole folder into dist/. That published whatever
upstream happened to put there - a README meant for repo readers, a
symlink, a format nobody had decided to support - and left the site
serving paths it had never validated.

Discovery now recognises exactly schemas/<name>/v<N>.<format>, one
directory deep, with an explicit format allowlist that holds only json
today. The build writes the files discovery accepted instead of copying
the folder, so the site publishes the layout it checked and nothing else.

Everything found and not served is recorded as a problem, distinguishing
files that are plainly local to the tool repo (README, dotfiles, which
stay silent) from names that were reaching for the version layout and
missed (v1.2.json, report-v0.json, a symlink where a schema belongs).
A typo that unpublishes a schema is otherwise indistinguishable from a
file that was never meant to be one.

Problems remain fatal for now; the next commit makes them survivable.
An empty schemas/ no longer fails - the folder can land upstream a
release before the first schema inside it does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single unservable file under schemas/ aborted the build. Since both CI
and deploy build from the newest flashtrace release, that meant one bad
schema in a release stopped the site from deploying at all - docs
included - and left it stale until upstream cut a fix.

The blast radius was wrong. Refusing to publish a broken schema is
right; refusing to publish the docs because a schema is broken is not.
Skipped files are now warnings, the build finishes, and everything that
validated still ships.

Quiet skipping would be its own failure, so the build writes the skips
to schema-problems.json (gitignored, outside dist/, not published).
Written on every build including a clean one, so the workflow reading it
can tell "nothing wrong" from "never got that far".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A readFileSync failure inside discovery surfaced as a bare `error:
EACCES ...` with nothing tying it to schemas/, leaving the reader to
work out which file it meant.

It is now reported like every other skip, naming the file, and the build
carries on - an unreadable schema is indistinguishable from a broken one
from a visitor's side, so it deserves the same treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every version entry carried the parsed document alongside its bytes, and
nothing in this PR read it - the site serves bytes and never a
re-serialisation.

The parse itself stays: it is what catches a release shipping a schema
that will not parse, and that check is the reason the URL can be treated
as a contract. Only the result is discarded, so there is no second
representation of a schema able to drift from the one being served.

Note for #10, which is stacked on this branch: src/schema-doc.mjs reads
versions[].json to render its page. It should parse the bytes where it
needs the document, keeping the parsed form local to the consumer that
wants it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
collectSchemaDir decided what every entry was and accumulated the result
in one loop, reaching a cognitive complexity of 25 against a limit of
15. The two jobs are separable: classifying an entry needs nothing but
the entry, and grouping needs nothing but the classification.

classifyEntry now answers with exactly one of a version to serve, a
problem to report, or null for a file that is the tool repo's own
business, which leaves the walk short enough to read at a glance. No
behaviour change - the synthetic checkout reports the same six problems
and serves the same five files.

Also drops the redundant A-Z half of the case-insensitive latest.<format>
character class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Skipping a broken schema keeps the site deploying, but a skip nobody
sees is just a silent outage of that schema - and a green deploy is
exactly where nobody looks. CI log output alone does not carry: the
release that breaks a schema is usually not the one anyone is watching.

Every run annotates each skipped file. Deploys additionally maintain one
tracking issue: opened on the first bad build, commented when the
problem set changes, silent when it has not, and closed automatically on
the first clean build. Deduplicated by a fingerprint of the problem set
carried in an HTML comment, so a persistent problem across ten deploys
is one issue with one comment rather than ten issues.

PR builds annotate but never file - a PR should not open an issue about
upstream content it did not touch.

The step is continue-on-error: reporting a problem must never become a
worse problem than the one being reported, which is the whole premise of
not failing the build in the first place.

report() takes gh as a parameter and the entry point sits behind an
argv guard, so the decision logic can be driven end to end against a
fake with no path to the real CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
public/ is copied over dist/ last, so a file there wins against a
generated file at the same path without a word. That is deliberate - it
is what makes public/ an escape hatch - but it is invisible at the point
where it would bite: a public/schemas/ or public/docs/ would shadow the
real thing and the build would still look right.

Noted at the copy in build.mjs, where someone debugging a wrong file
will be looking, and in the CLAUDE.md structure table, where someone
deciding to put a file in public/ will be.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MentorFilou and others added 8 commits July 21, 2026 00:31
The issue reported problems by accumulating comments, so the current
state was whatever the newest comment said and a reader had to
reconstruct it by scrolling. Someone opening the issue should see what
is wrong now.

The body is now the live view: a short instruction and a table of the
most recent build's skips, rewritten in place on every change without
touching the issue's state. Each change also appends a comment saying
what actually moved - which files stopped being served, which came back,
which are failing for a different reason than before - so the history is
still there for anyone who wants it.

The body carries the problem set it was written from, in a comment
marker. That is what lets the next run tell an unchanged set from a
changed one and diff the two, with no state kept anywhere else to fall
out of step with the issue. A body edited into nonsense degrades to
"cannot diff" and is rewritten, rather than producing a wrong diff.

Closing now rewrites the body first, so someone arriving from the close
notification does not land on a table of problems that no longer exist,
and the closing comment lists what was fixed.

Reasons are a parser's own words, so table cells escape pipes and
newlines and the embedded state escapes > - an error message quoting
JSON should not be able to break the row or close the comment early.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema reports named only the flashtrace release, but that is the one
coordinate a fix here does not move. A layout that changed upstream on
purpose is resolved by teaching src/schemas.mjs about it and merging to
main, which redeploys against the same latest release - so the issue read
"problems at v1.2.3" then "resolved at v1.2.3", crediting a release that
never changed and leaving no way to see why it works now. The open
direction was equally wrong: a regression in src/schemas.mjs filed an
issue blaming an innocent release.

Every body and comment now states both: the release the docs and schemas
were built from, and the linked .github.io commit that built them. Taken
from GITHUB_SHA, falling back to the local checkout, and omitted entirely
when neither is available.

Deliberately not part of the fingerprint - the sha moves on every merge to
main, so folding it in would make unrelated deploys re-comment on an
unchanged issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reason is a parser's own words, so it can hold a newline - and a
newline in a workflow command ends that command and starts whatever
follows it, letting upstream content emit ::add-mask::, ::error:: or any
other command into the run's stream.

cell() already collapses newlines for the table path; the annotation
path did not use it. Reuse it there, and add GitHub's own percent
escaping on top: %25/%0D/%0A for the message, plus %3A/%2C for title=,
where : and , separate the properties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A null previous state means "cannot diff", not "diffed to nothing", but
it was passed as `previous ?? []` into diffProblems - so an issue body
that had lost its state marker produced a comment announcing every
long-standing problem as newly broken, and a close comment with a silent
gap where the list of recovered files should be.

Honour the distinction the comment already claimed: an unreadable state
gets its own comment stating the current set and saying plainly that it
is not a comparison, and closeComment says so rather than listing
nothing. Both self-heal - the body is re-stamped in the same run, so the
next build can diff again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The close path writes the body before closing, so a reader arriving from
the close notification does not land on a table of problems that no
longer exist. Nothing caught a failure between the two calls, though, and
execFileSync throws on a non-zero gh: a failed close left an open issue
whose body claims to be resolved and whose table is gone - worse than
either call not having happened.

Keep the ordering, and make it recoverable. The pre-existing body is now
retained rather than only parsed, and a failed close puts it back, so the
next clean build retries the whole thing. If the restore fails too, the
log says exactly what state the issue is in. The error is rethrown either
way: the step is continue-on-error, so it surfaces on the run without
failing the deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSON.stringify over the raw array folds array order and key insertion
order into the hash, so a discovery pass returning the same problems in a
different order - or upstream adding an incidental field like a line
number or a timestamp - would read as a changed set and re-comment on
every deploy. The comment claimed discovery sorts by path; nothing here
enforced it.

Normalise to sorted [path, reason] tuples before hashing. Sorted by code
point rather than localeCompare, which can call two distinct paths equal
and leave their order dependent on arrival order - the same instability.

No migration: the fingerprint stored in the issue body is informational,
and the staying-quiet check recomputes both sides from their problem
sets, so an issue open across this change still compares correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Without --repo, gh infers the repository from the working directory, so
the script depended on being run from a checkout whose origin points at
this repository - an assumption the sha handling already declines to
make. Pass $GITHUB_REPOSITORY when it is set, and keep gh's inference for
local runs without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the rationale that is not derivable from the source - why schemas
bypass the markdown pipeline, why latest is a file rather than a
redirect, why the close is ordered body-then-state - and drops the prose
that narrated what the next line already says.

Also removes three points that were made twice: the parsed JSON being
discarded (header and parse site), the public/ shadowing rule (build.mjs
and CLAUDE.md), and the cannot-diff distinction (state read and its
consumers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MentorFilou
MentorFilou merged commit e2dd4d7 into main Jul 21, 2026
1 check passed
@MentorFilou
MentorFilou deleted the feat/serve-schemas branch July 21, 2026 13:32
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