0.9.3 — accuracy pass: unstick CI, and gate the claims nothing was checking - #1
Conversation
The gate has never passed on CI. It was added in d2ca476 and pushed in the same batch as 27 other commits, so its very first run was already red, and it has stayed red for sixteen consecutive runs across six days (last green 623aa05, 2026-07-26) — through all three npm publishes: 0.9.0, 0.9.1 and 0.9.2. The failure was real, not flaky. `Updated` rendered item.updated_at as a bare ISO timestamp with no break-all, while every other value in that card (project, source/reporter, assignee, route) already had one. An ISO string is a single 24-char token with no break opportunity, so at 200% text zoom it set a 236px min-content floor that the Section's min-w-0 could not shrink. On macOS that lands at 301px and passes at a 320px viewport; on CI's Linux font metrics the same string measures ~325px, which is exactly the {"s":325,"c":320} in every failing run — and why this reproduced on CI but not locally. Adding break-all clears 320px with 40px of headroom: measured passing down to 280px, against a ~24px macOS/Linux metric delta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.9.0, 0.9.1 and 0.9.2 were all published while ci.yml was failing. Nothing in the pipeline objected, because nothing in it ever asked. The gates verify the source; verify:release deliberately runs after publishing, against published artifacts, where npm's immutability means it can only report damage. release-preflight asks the missing question before the fact: clean tree, commit actually pushed, and a completed successful ci.yml run for that exact sha. It is wired as prepublishOnly, so `npm publish` enforces it rather than trusting anyone to remember. It fails closed. An unreachable GitHub is a refusal, not a pass — "unknown" is the state that let the last three releases through. LOOPBACK_ALLOW_RED_CI=1 is the explicit, auditable override. One trap found by testing rather than reading: the GitHub API matches head_sha on the full 40-char sha only, so an abbreviated sha returns an empty run list — indistinguishable from "never tested". Shas are resolved locally first. Fail-closed gates have their own failure mode: one that refuses everything would sail through a canary that only checks for a non-zero exit. So the sweep gets both directions, anchored to two immutable commits — 10a1c6d (red) must fail, 623aa05 (green) must pass — and `expect` in the harness to express the second. The canary job now gets GH_TOKEN, without which the red case would pass for the wrong reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The structural gates were all green while the docs drifted, because none of
them ever read a claim. Everything that had rotted was a hand-typed figure.
Corrected against the code:
- the widget was quoted four ways — 46KB/15KB, 57KB/19KB, 57,183 B, ~29KB —
against a real 59,443 B. Two separate commits each said they had "corrected
the widget size" while fixing one occurrence of two;
- "The MCP bus — 10 tools" sat above a 9-row table, missing
loopback_update_feedback, which the 0.8.0 changelog had announced;
- the HTTP surface table omitted all three attachment endpoints;
- the shadcn section documented two registry items and shipped three, and its
prose had a sentence cut off mid-clause with a contradictory paste over it
and an orphan "free)." — live on GitHub and in the npm readme;
- the repo map said init writes .claude-plugin/ (it does not) and that
init-gate re-renders it (it asserts a version), and omitted .github/ and
.impeccable/ entirely.
docs-facts-gate now re-derives all of it: size, tool count, every registered
route, every registry item, the canary count, the published version.
Measuring it exposed a smaller lie inside the bigger one. "19,751 B gzipped on
the wire" was measured with the gzip(1) CLI, but the server serves gzipSync at
zlib's default level — 19,770 B. Three plausible numbers for one file, so the
gate measures it the way src/http.ts actually produces it.
link-gate replaces a CI step that had never checked anything. `linkinator
README.md docs integrations` silently ignores every path after the first, and
`--skip "127.0.0.1|..."` matched the root linkinator serves the files from, so
the crawl never started: that one pattern took the scan from 24 links to 0, and
"Successfully scanned 0 links" exits 0. It now asserts a minimum link count per
target, because the failure was never a broken link — it was an empty crawl.
Both new gates are canaried, and the sweep is 26 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every parity check ran from one canonical source down to its renderings —
canonical→installed, instructions-src→AGENTS block, plugin→canonical. Nothing
compared the two canonical sources to EACH OTHER, and instructions-src.md's own
header admits the sync is manual: the skill body "mirrors this text and must be
updated with it". That was the last drift class in the repo with no check.
They are deliberately not byte-identical — the skill carries frontmatter and
resolves its own project slug, the playbook gets {{PROJECT}} substituted — so a
diff cannot be the gate. The invariant is the loop: both must drive the same
tools in the same order. An extra step in one is an agent working a different
loop depending on which file it read.
Also untracks dashboard/tsconfig.tsbuildinfo, tsc's incremental cache and the
only tracked build artifact that was not deliberate. public/ stays committed —
that one IS the distribution mechanism.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its opening line says "see the attached zip", which dangles here: this file is committed verbatim from the workspace repo, and no zip exists in this one. The note names what the archive was (now commit b9d1567) and flags which of its figures are Phase-0 rather than current — 9 tools, no dashboard, no HTTP auth. Applied identically in the workspace repo so both copies stay byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tag discipline stopped exactly when public publishing began. v0.3.0 through v0.8.0 are all tagged; 0.9.0, 0.9.1 and 0.9.2 — the only three versions that ever reached npm — were not, so the releases people actually installed are the ones nobody can check out. v0.9.0 (f472687), v0.9.1 (17bd8ba) and v0.9.2 (2f96190) are now tagged retroactively, each verified against the package.json version at that commit. release-preflight now refuses to publish unless v<version> exists AND points at the commit being published, so this cannot silently lapse again, and `npm run bump` prints the tag command as part of the release sequence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GitHub scored this repo's community health at 42%: LICENSE and README and nothing else. For a project whose whole pitch is that an adopter can wire it into their own agents in two minutes, the absence of a contributing guide and a private disclosure channel is a real barrier, not a metadata gap. CONTRIBUTING states the doctrine the repo already runs on rather than inventing a new one: a gate that cannot fail is decorative, so a new gate needs a canary case and a new number in the docs needs a docs-facts-gate check. It lists the gate suite as CI actually invokes it, and points at the README's repo map instead of copying it, so it cannot drift. SECURITY documents the model that already exists in the code, verified line by line rather than described from memory: origins pinned at startup instead of read from Host (DNS rebinding), the cross-origin pin projection that keeps captured response bodies from ever leaving, the token on non-loopback binds moved into an HttpOnly SameSite=Lax cookie and compared with timingSafeEqual over SHA-256, the four endpoints deliberately left open, and the 60/min intake limit. It is equally explicit about what is NOT a vulnerability — unauthenticated local filing is the product, and deployed public sites are an unsupported surface since Chrome 142+ blocks them. Also gives docs-facts-gate and link-gate the `npm run` aliases every other gate already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six coupled version fields via `npm run bump`, plus the changelog entry for the accuracy pass: the a11y fix that unsticks CI, release-preflight, docs-facts-gate, link-gate, the canonical-source seam check, and the backfilled 0.9.x tags. This one has to be a release rather than a docs commit: the mangled shadcn paragraph is in the published 0.9.2 readme, and an npm readme can only be replaced by publishing over it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README said "Three endpoints stay open on a LAN bind" and listed three, while requireAuth lets four through — GET /health was open in the code and absent from the table. A wrong count in the security section is the worst place for one, and it was found by a reviewer reading src/http.ts, not by anything in CI. docs-facts-gate now derives the open set from requireAuth itself and checks three things against it: every open path appears in the table, the row count matches, and the English number in the sentence above the table matches too. That sentence is exactly what went stale, so counting only the rows would have missed it. Canaried, and the sweep is 28. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The open-endpoint check I added one commit ago substring-searched the README table for each path requireAuth leaves open. The canary mutation renames that row to `/health-REMOVED` — which still CONTAINS `/health`, so the check passed with its subject broken and the sweep correctly reported it as decorative. It now parses the path out of each row's `METHOD /path` cell and compares sets in both directions, so a renamed row, a missing row and an invented row all fail. Re-canaried: the mutation trips it. Worth noting how close this came to shipping green. The first time I ran this case in isolation it reported a pass — but the gate was failing on an unrelated stale canary count, not on the mutation. A gate failing for the wrong reason looks exactly like a gate working, which is the same false-green class the sweep's own no-op-mutation guard exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
My own break-all fix, caught by testing the running app rather than the gate. It satisfied the 320px reflow rule and passed CI, but at 1280px the grid column is ~166px and a 24-char ISO string does not fit — so break-all did exactly what it says and split the timestamp across two lines mid-number. Rendered like the Age field directly above it instead: humanised text in a <time>, with the exact value kept in title and dateTime. That removes the unbreakable token rather than breaking it, so the reflow floor is unchanged (still clears 320px, measured passing down to 280px) and the desktop rendering is one clean line. It also makes the two adjacent timestamp fields consistent, which they were not before. No gate could have caught this: every a11y assertion is about overflow, and wrapping mid-token is not overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9a6acfd made the server.json description scope-honest and took it to 137 characters. The MCP Registry caps it at 100, and `npm run smoke` asserts exactly that — so main went red the moment it landed. Caught by rebasing onto it, not by anything that ran before the push. Keeps the intent (the app you're building, a fix turning the pin green) at 98 characters. Also lists CONTRIBUTING.md, SECURITY.md and CHANGELOG.md in the repo map, and corrects "the six gates" to seven now docs-facts-gate is in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 681407db9a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| cmd: ["node", ["scripts/release-preflight.mjs", "--sha", "623aa05a2d75e4bbeffcc5cfde903babc2a332d7"]], | ||
| guards: "a green commit still being publishable", | ||
| expect: 0, |
There was a problem hiding this comment.
Fetch the historical canary commits
The canary.yml workflow uses a plain actions/checkout@v4, and checkout fetches only the triggering commit by default. Consequently these historical SHAs are absent in CI: release-preflight fails at its local git rev-parse before querying GitHub, so the red case passes for the wrong reason and this green case always returns nonzero, making every canary workflow fail. Configure fetch-depth: 0 or explicitly fetch both anchor commits before running the sweep.
Useful? React with 👍 / 👎.
| const tagged = tryRun("git", ["rev-list", "-n", "1", tag]); | ||
| if (!tagged.out) { | ||
| bad(`${tag} does not exist — tag the release commit before publishing:\n git tag -a ${tag} -m "${tag}" && git push origin ${tag}`); | ||
| } else if (tagged.out !== sha) { | ||
| bad(`${tag} points at ${tagged.out.slice(0, 7)}, not the commit being published (${sha.slice(0, 7)})`); | ||
| } else { | ||
| ok(`${tag} points at the commit being published`); |
There was a problem hiding this comment.
Verify that the release tag was pushed
When the maintainer creates the version tag locally but forgets git push origin v<version>, this check still passes because git rev-list only inspects the local repository. npm publish can therefore release another version with no corresponding tag on GitHub, despite this gate being intended to prevent exactly that traceability failure. Check the remote tag and its peeled commit—for example via git ls-remote --tags—rather than accepting only a local tag.
Useful? React with 👍 / 👎.
| if (process.env.LOOPBACK_ALLOW_RED_CI === "1") { | ||
| console.warn( | ||
| "\n⚠️ LOOPBACK_ALLOW_RED_CI=1 — publishing WITHOUT a green CI run.\n" + | ||
| " This is the override, not a pass. Say so in the release notes.\n", | ||
| ); | ||
| process.exit(0); |
There was a problem hiding this comment.
Preserve integrity checks when overriding red CI
When LOOPBACK_ALLOW_RED_CI=1 is used for its documented emergency purpose, this early exit also skips the clean-tree, pushed-commit, and version-tag checks below. An operator overriding only a known CI failure can therefore accidentally publish dirty files that CI never tested or an unpushed/untagged commit. Record the override here but bypass only the GitHub CI-conclusion check, allowing the local and repository-integrity checks to continue running.
Useful? React with 👍 / 👎.
| const undocumented = routes.filter((r) => { | ||
| const path = r.split(" ")[1]; | ||
| return !readme.includes(path); |
There was a problem hiding this comment.
Restrict route checks to the HTTP surface table
When a route is removed from the HTTP surface table but remains mentioned elsewhere in the README, this global substring check still reports it as documented. This already affects routes such as /health, /ingest, /feedback, /widget.js, and /mcp, which occur in multiple README sections, so deleting their actual table rows leaves the new gate green despite its stated assertion. Extract the HTTP surface table first and match method/path rows within that section.
Useful? React with 👍 / 👎.
| mutate("README.md", (s) => | ||
| s.replace( | ||
| "https://github.com/getsentry/sentry-mcp", | ||
| "https://github.com/joshidikshant/loopback/tree/main/this-path-does-not-exist-canary", |
There was a problem hiding this comment.
Trigger the canary when README anchors change
These new canaries mutate README.md, but the inspected canary.yml push and pull-request path filters omit README.md. A README-only edit can therefore remove or rewrite one of these mutation anchors without running the meta-gate; the ordinary documentation checks may remain green while mutate() would now be a no-op, and the broken canary is only discovered by the weekly run. Add README.md (and any other newly mutated documentation paths) to both workflow path filters.
Useful? React with 👍 / 👎.
| const quoted = [...roadmap.matchAll(/loopback-mcp-server@([\d.]+)/g)].map((m) => m[1]); | ||
| for (const v of quoted) { | ||
| check( | ||
| v === latest || v === pkg.version, | ||
| `ROADMAP quotes loopback-mcp-server@${v}; npm latest is ${latest}, tree is ${pkg.version}`, |
There was a problem hiding this comment.
Keep the documented npm version valid after publishing
When this 0.9.3 release is published, npm view will return 0.9.3 and pkg.version is already 0.9.3, but the committed ROADMAP still quotes loopback-mcp-server@0.9.2. The condition therefore changes from passing before publication to failing afterward, so the next otherwise-unrelated push or pull request with npm connectivity makes ci.yml red again. Update the ROADMAP to 0.9.3 in this release commit, or avoid making a historical published-version statement fail based on the mutable latest tag.
Useful? React with 👍 / 👎.
Two bugs found by driving the built CLI rather than reading it. `--version` printed nothing and hung. It was not a known flag, so it fell through to the default branch, started a stdio server, and waited on a stdin a human terminal never closes — having already opened the user's real ~/.loopback/loopback.db. Any typo behaved identically. argv is now checked BEFORE the store is constructed, which is the part that matters: --version and -v print the version and exit 0, anything unrecognised names itself on stderr and exits 1, and neither opens a database. The e2e assertion points the run at a directory that must stay empty afterwards, because an exit code alone would not have caught the DB being touched. A whitespace-only title was accepted while an empty string was correctly rejected: " " is three characters and cleared min(3), so the queue could hold an item nothing could act on. The length checks now run on the trimmed value — title, project, claiming agent, comment author and comment body, on submit and update alike, since the same mistake was sitting in all of them. It also stops padded slugs being stored with their padding. Both canaried, in the direction that matters: drop the trim and " " is three characters again; let unknown args fall through and the hang comes back. Sweep is 30. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI on
mainhad been red for sixteen consecutive runs across six days (last green623aa05, 2026-07-26). 0.9.0, 0.9.1 and 0.9.2 were all published on top of it, so all three shipped two real WCAG failures as product defects rather than just a red badge. The a11y gate had in fact never passed on CI once — it landed ind2ca476pushed alongside 27 other commits, so its first run was already red.The bug was one line, and platform-specific
Updatedrendereditem.updated_atas a bare ISO timestamp, while every other value in that card already carriedbreak-all. An ISO string is a single 24-char token with no break opportunity, so at 200% zoom it set a 236px min-content floor thatmin-w-0could not shrink. On macOS that measures 301px and passes a 320px viewport; on CI's Linux font metrics the same string is ~325px — exactly the{"s":325,"c":320}in every failing run, and why it never reproduced locally.Fixed by rendering it like the
Agefield beside it: humanised text in a<time>, exact value kept intitle/dateTime. That removes the unbreakable token instead of breaking it. (The first attempt wasbreak-all; it passed CI but split the timestamp mid-number at desktop width — caught by driving the running app, not by a gate.)Four gates for four things nothing was checking
release-preflight(prepublishOnly) — refuses to publish unless the tree is clean, the commit is pushed and tagged, andci.ymlconcludedsuccesson that exact sha. Fails closed: an unreachable GitHub is a refusal, since "unknown" is the state that let three releases through. Override isLOOPBACK_ALLOW_RED_CI=1.docs-facts-gate— re-derives the widget size, tool count, HTTP routes, registry items, canary count and open-on-LAN endpoints from the code. Every figure that had drifted was hand-typed and read by no gate.link-gate— the previous link check had been green since the day it was added while scanning zero links:linkinatorignores every path after the first, and the bare127.0.0.1skip matched the root it serves files from. Now asserts a minimum link count per target.init-gate— now covers the seam between the two canonical sources, whose sync was manual and ungated.All four are mutation-canaried; the sweep is 28. One of them caught my own gate being decorative mid-review, which is the argument for having it.
Docs corrected against the code
The widget was quoted four ways (46KB/15KB, 57KB/19KB, 57,183 B, ~29KB) against a real 59,443 B, with two prior commits each claiming to have fixed it. "The MCP bus — 10 tools" sat above a 9-row table. The HTTP table omitted all three attachment endpoints. The shadcn section documented two registry items of three and had a paragraph cut off mid-clause with a contradictory paste over it — live on GitHub and in the published npm readme, which is why this needs a release rather than a docs commit. The security section said three endpoints stay open on a LAN bind;
requireAuthallows four.Also adds
CONTRIBUTING.md,SECURITY.mdand issue/PR templates (community health was 42%), and backfills tagsv0.9.0–v0.9.2— tag discipline had stopped exactly when public publishing began.Verification
All ten gates pass locally on macOS, plus a 28-case canary sweep. The Linux run is what this PR is for: the original failure only reproduced on CI's font metrics, so please let
ci.ymlconfirm the reflow fix rather than trusting the local measurement.Tag
v0.9.3is deliberately not pushed — it would dangle if this is squash-merged. Tag whatever commit actually lands, then publish.🤖 Generated with Claude Code