Repository and CI health β€” the checks a repository cannot run on itself

Hosting/RepoHealth is the fleet's repository audit. Every check it performs was done BY HAND on 2026-09-01, and every one of them found a live defect in a repository whose CI was entirely green. That is the common thread and the reason this is a node type rather than a shell script: each of these failures is a failure of reporting, and a wall of ticks reports none of them.

🚨 An unmeasured check is never clean. A default report reads unmeasured, not clean; a repository the scan could not read, a pull-request file list that was truncated, a rename whose previous path GitHub did not report β€” each lands in Unmeasured, which forces the verdict away from clean whatever the finding count says. An audit that turns a read failure into a green tick is worse than no audit, because it is believed.

Where it lives

Type Hosting/RepoHealth β€” content RepoHealthReport
Areas Content (one recorded scan) Β· Fleet (every recorded scan, merged, newest per repository) Β· Scan (measure NOW against GitHub) Β· Tests
Credential the fleet's existing GitHubAppTokenService installation token β€” the same one DeploymentGitSync opens record PRs with. No new secret. An instance without the App configured gets a report that says the scan was skipped; it never gets a clean one.
I/O one IIoPool leaf (IoPoolNames.Http). Everything above it is IObservable<T>; the only await is inside the leaf.
Policy RepoHealthPolicy β€” authorable on the node, defaulting to RepoHealthPolicy.Fleet: the platform plus this catalog and the four satellites.

The six checks

1. Required contexts β€” read BOTH protection shapes or report the wrong repo

GitHub has two independent protection mechanisms and enforces both. Classic branch protection lives at /repos/{slug}/branches/main/protection; rulesets are separate, and their effective rules for a branch are at /repos/{slug}/rules/branches/main. RepoGateRules.Read reads both and UNIONs the required contexts.

🚨 The platform repo answers 404 Branch not protected on the classic endpoint while a ruleset named main pr protection requires a context, runs a merge queue and forbids force-pushes. An audit that reads one endpoint reports the most protected repository in the fleet as the least protected one β€” and gets discarded as broken, which is how a real audit dies.

🚨 And a read that FAILED is not "unprotected" either. A 403, a 500, a token without the scope all return no contexts, and a rule that counts contexts cannot tell them from a branch that requires none. BranchProtection.Measured separates them; the finding for an unmeasured branch is a Warning that says so, never a Critical that says "unprotected".

What it found. At 2026-09-01 ~14:24 UTC this catalog's main required exactly two contexts β€” Validate node repos and Compile every NodeType (vs core). The job that builds the portal hosts and runs ~3,000 tests, Build + test the portal hosts, was not one of them, so a PR merged with it RED and main was broken for five hours, blocking 21 pull requests. Nobody knew, because nothing reported it. (Re-measured at 14:29 the same day the context was present β€” remediated by hand during the session that found it. The check stays, because nothing prevents it recurring.)

On 2026-09-11 the same hole was closed one lane further along: test-repos, the only lane that executes a NodeType's Tests area, was reporting on every PR and gating nothing β€” #1624 auto-merged with it RED. It is now the seventh required context here. Which seven, what each proves, and the 45-PR measurement that made adding it safe rather than repo-wide fatal: RequiredStatusChecks.md.

A sixth thing this rule catches for free: a required context that names no job in the repository's own workflows. It never turns green β€” the PR sits at "Expected" forever and reads as merely pending. Adopting a reusable workflow renames a repo's contexts, so this is the failure mode of every CI migration. It is a Warning rather than a Critical, and it is suppressed entirely when no workflow could be read: an unread workflow set is an absence of evidence, not evidence of absence.

2. Armed state β€” a queue entry OR an auto-merge request, never one alone

public bool IsArmed() => QueueState is not null || AutoMergeEnabled;

🚨 On a merge-queue repository gh pr merge <N> --merge --auto behaves two different ways:

The required checks are… What happens mergeQueueEntry autoMergeRequest
already green it ENQUEUES immediately set null
not yet green it records an auto-merge request null set

Both mean armed. Reading autoMergeRequest alone calls every enqueued PR idle; reading mergeQueueEntry alone calls every auto-merge-armed PR idle. This was measured backwards three times in one session before the predicate was written down.

What it found. Ten pull requests on the platform repo sat green, mergeable and unarmed. A PR in that state is invisible: not failing, not blocked, not in anybody's way, and nothing will ever land it. The finding names the number and prints the exact command that arms it.

3. Queue health β€” UNMERGEABLE is not a conflict with main

An UNMERGEABLE merge-queue entry conflicts with the pull requests ahead of it in the queue, not with the base branch. So its own PR page keeps reading MERGEABLE/CLEAN while every speculative build stacked behind it is wasted. Anyone looking at the PR sees a healthy PR; anyone looking at the queue sees a stalled queue; nothing connects the two.

The report states the contradiction explicitly β€” the queue state, the position, and what the PR page still says β€” because reading either half alone leads away from the fix.

A genuinely conflicting PR is a different, separately reported finding: it gets no check suite at all, so the green it renders was earned before the conflict and proves nothing about its current head. (This is the same trap scripts/pr-ready.py exists for.)

4. Cross-repo breaks β€” the highest-value check, and nothing else can run it

Neither repository's CI can see this one. The platform does not build this catalog; this catalog reaches the platform through $(MeshWeaverRoot) and β€” at four sites in ci.yml β€” checks it out with no ref:, so it builds against the platform's moving main.

CrossRepoRules closes it: parse every ProjectReference that goes through $(MeshWeaverRoot), read the upstream's open pull requests with the files each one changes, and report any reference to a path an open PR deletes or moves.

What it found. Platform PR #2941 ("The indexing pipeline, the map control and Cornerstone leave the content surface") is still open, and it deletes three project files, not one. Run over this repo's real tree β€” 567 $(MeshWeaverRoot) references across 116 project files, against all 14 open platform pull requests β€” the rule reports:

Deleted by #2941 References here Verdict
src/MeshWeaver.Maps/MeshWeaver.Maps.csproj 11, 0 unconditional Warning
src/MeshWeaver.ContentCollections.Indexing/…csproj 2, 2 unconditional πŸ”΄ Critical
src/MeshWeaver.ContentCollections.Indexing.Graph/…csproj 2, 2 unconditional πŸ”΄ Critical

The Maps row is the one that was caught by hand: it reached merge-queue position 2, and its eleven references have since been hardened with an Exists() fallback that prefers this repo's own copy and falls back to $(MeshWeaverRoot) β€” which is why the rule now grades it Warning rather than Critical, exactly as designed.

The two Indexing rows are the ones the hand audit missed. Those projects already exist in THIS repo, and src/MeshWeaver.AI/MeshWeaver.AI.csproj (lines 51–52) and src/Memex.Portal.Gui/Memex.Portal.Gui.csproj (lines 103–104) still reach for the platform's copies unconditionally. When #2941 merges, restore fails for the AI engine and the portal host β€” which is the Build + test the portal hosts job. The same Exists() fallback the Maps references got is the fix, and the sequencing belongs with #2941.

That is the whole argument for this check being a node type rather than a person: the hand audit found one of three, because a person greps for the project they already suspect.

5. Unpinned upstream β€” a gate that is a function of somebody else's trunk

WorkflowRules classifies every actions/checkout of the upstream:

Pin Meaning Finding
no ref: the action's default β€” the upstream's default branch Warning
a branch, or ${{ vars.X \|\| 'main' }} moving, but at least declared Info
an expression with no literal fallback not classifiable β€” said so, never guessed β€”
a tag or a 40-hex sha immutable β€”

A ref: belongs to the step whose with: block it sits in; the scan reads indentation rather than parsing YAML, and a step with no ref: must never borrow the next step's β€” a case the tests pin.

What it found in this repo: four unpinned checkouts of the platform (ci.yml lines 531, 1035, 1248, 1776), three moving-ref ones (log-watcher-image.yml, portal-ai-image.yml, portal-next-image.yml), and one expression-pinned (ci.yml:1075, the catalog pin). The hand-written brief said three; the measurement says four.

6. Main health β€” is the trunk RED, and is the answer even decisive

The first five checks all audit work trying to land: which contexts a branch requires, whether a pull request is armed, whether a queue entry is poisoning the stack behind it, what an upstream PR would break. None of them looks at what has already landed. MainBranchRules asks the one question nothing else here asks, about the one branch everything else is built from.

What it cost to be missing. This catalog's main was red from 09:52Z on 2026-09-06 β€” runs 4642, 4644 and 4648 of Plugin Catalog CI, at 09:52, 10:08 and 11:03 β€” and it was found by a person hours later while every board this type renders was reporting on pull requests. Core is no better covered: its protection is a merge queue, which gates what enters and watches nothing afterwards.

🚨 "The newest completed run" is the wrong reader, and this fleet is full of the proof. A cancelled run (a concurrency group superseding it) and a skipped one (every job filtered out) are both completed and both decided nothing:

Read on 2026-09-06 Newest completed run What it decided
Plugin Catalog CI, 12:10Z 12:09 cancelled nothing β€” the 11:03 failure below it was the state of the branch
Retry known transients, 17:05Z 17:04 skipped (six of them in a row) nothing β€” the 16:45 success below them was

So the rule reads the newest decisive run per workflow, stepping over anything that decided nothing. Taking the newest completed run literally would have read the first row as healthy while the trunk was red, and the second as a pass on evidence nobody produced.

Severity, and the one thing that softens it. A red trunk with nothing newer running is Critical β€” it is the current state of the branch, and everything built from it carries the failure. When a newer run of the same workflow is already in flight the finding drops to Warning and says so, because "newest completed" and "current state" diverge exactly while something is being repaired β€” which is precisely when somebody is looking. It never disappears, and the in-flight run is named, so a run parked in queued for ever is visible rather than a silent excuse for the red beneath it.

Every workflow is judged, with no per-repository list of "the ones that count". Such a list is exactly the configuration that goes stale and then measures nothing. A workflow permanently red on the trunk is a defect to fix or a workflow to delete; the finding names it so the reader can tell.

REST, not GraphQL β€” deliberately, and the reason is a trap this fleet has paid for. GraphQL's answer to "is it red" is statusCheckRollup, whose conclusion comes back as the empty string rather than null when nothing ran, so every naive comparison reads it as "not failing". REST returns status and conclusion as separate fields, so not finished, finished with no verdict and success cannot be confused with one another. Belt and braces anyway: a completed run with an empty or unrecognised conclusion is never green β€” an empty one is unmeasured, and an unrecognised one is reported, so the day GitHub adds a verdict this check says so instead of quietly reading a repository as clean.

What it cannot see, stated rather than implied. A run that concluded success because a job inside it was skipped reads green here. That is the skip-trapdoor, and the rule against it (no if: asking whether a secret is set; a preflight that fails RED) is what covers it β€” a run-conclusion reader cannot. Likewise, a check suite an installed App never posts runs to stays queued for ever: it cannot reach this check at all, which reads this repository's own Actions runs, never check suites, and never waits for anything.

Unmeasured, not clean. If the API refused, if the branch has no runs at all, or if every run in the window was cancelled, skipped or still going, the repository lands in Unmeasured β€” which forces the report's verdict away from clean. A branch nobody could read is not a healthy branch.

Reading a report

Cost

One scan is roughly three REST calls plus one GraphQL round trip per repository, one GraphQL call for the upstream's open pull requests (files included β€” the expensive part is one request, not one per PR), and two more calls only when an upstream PR actually removes or moves a project file. On the default six-repo fleet that is about twenty-six requests against a 5,000/hour installation budget. The default-branch run list is ONE page of at most 100 runs per repository, sized so the cancelled and skipped runs that stack above a real verdict cannot push it out of the window. The upstream PR page size is capped by maxUpstreamPullRequests; hitting the cap is recorded as unmeasured, never silently truncated.

What it deliberately does NOT do

It reports; it does not command. It does not arm a pull request, dequeue an entry, edit branch protection, re-run a red workflow, revert a commit, or open a PR against anything. Branch protection is system-changing (AGENTS.md), so the node's job ends at naming the repository, the context, the PR number and the file.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.